diff --git a/scripts/tests/cirque_tests.sh b/scripts/tests/cirque_tests.sh index 07f243c8bc13ac..6f8f41215ce391 100755 --- a/scripts/tests/cirque_tests.sh +++ b/scripts/tests/cirque_tests.sh @@ -44,6 +44,7 @@ CIRQUE_TESTS=( "SplitCommissioningTest" "CommissioningFailureTest" "CommissioningFailureOnReportTest" + "PythonCommissioningTest" "CommissioningWindowTest" ) diff --git a/src/controller/python/BUILD.gn b/src/controller/python/BUILD.gn index da9b080e4fcd6d..750de2b837c170 100644 --- a/src/controller/python/BUILD.gn +++ b/src/controller/python/BUILD.gn @@ -68,6 +68,8 @@ shared_library("ChipDeviceCtrl") { "OpCredsBinding.cpp", "chip/clusters/attribute.cpp", "chip/clusters/command.cpp", + "chip/credentials/cert.cpp", + "chip/crypto/p256keypair.cpp", "chip/discovery/NodeResolution.cpp", "chip/interaction_model/Delegate.cpp", "chip/interaction_model/Delegate.h", @@ -213,7 +215,13 @@ chip_python_wheel_action("chip-core") { "chip/clusters/Command.py", "chip/clusters/__init__.py", "chip/clusters/enum.py", + "chip/commissioning/__init__.py", + "chip/commissioning/commissioning_flow_blocks.py", + "chip/commissioning/pase.py", "chip/configuration/__init__.py", + "chip/credentials/cert.py", + "chip/crypto/fabric.py", + "chip/crypto/p256keypair.py", "chip/discovery/__init__.py", "chip/discovery/library_handle.py", "chip/discovery/types.py", @@ -270,7 +278,10 @@ chip_python_wheel_action("chip-core") { "chip.ble", "chip.ble.commissioning", "chip.configuration", + "chip.commissioning", "chip.clusters", + "chip.credentials", + "chip.crypto", "chip.utils", "chip.discovery", "chip.exceptions", diff --git a/src/controller/python/OpCredsBinding.cpp b/src/controller/python/OpCredsBinding.cpp index c7f3e1dc20497c..3c4da74f904683 100644 --- a/src/controller/python/OpCredsBinding.cpp +++ b/src/controller/python/OpCredsBinding.cpp @@ -23,6 +23,7 @@ #include "ChipDeviceController-ScriptDevicePairingDelegate.h" #include "ChipDeviceController-StorageDelegate.h" +#include "controller/python/chip/crypto/p256keypair.h" #include "controller/python/chip/interaction_model/Delegate.h" #include @@ -36,6 +37,7 @@ #include #include +#include #include #include #include @@ -57,6 +59,8 @@ const chip::Credentials::AttestationTrustStore * GetTestFileAttestationTrustStor return &attestationTrustStore; } + +chip::Python::PlaceholderOperationalCredentialsIssuer sPlaceholderOperationalCredentialsIssuer; } // namespace namespace chip { @@ -369,11 +373,74 @@ void pychip_OnCommissioningStatusUpdate(chip::PeerId peerId, chip::Controller::C return sTestCommissioner.OnCommissioningStatusUpdate(peerId, stageCompleted, err); } +/** + * Allocates a controller that does not use auto-commisioning. + * + * TODO(#25214): Need clean up API + * + */ +PyChipError pychip_OpCreds_AllocateControllerForPythonCommissioningFLow(chip::Controller::DeviceCommissioner ** outDevCtrl, + chip::python::pychip_P256Keypair * operationalKey, + uint8_t * noc, uint32_t nocLen, uint8_t * icac, + uint32_t icacLen, uint8_t * rcac, uint32_t rcacLen, + const uint8_t * ipk, uint32_t ipkLen, + chip::VendorId adminVendorId, bool enableServerInteractions) +{ + ReturnErrorCodeIf(nocLen > Controller::kMaxCHIPDERCertLength, ToPyChipError(CHIP_ERROR_NO_MEMORY)); + ReturnErrorCodeIf(icacLen > Controller::kMaxCHIPDERCertLength, ToPyChipError(CHIP_ERROR_NO_MEMORY)); + ReturnErrorCodeIf(rcacLen > Controller::kMaxCHIPDERCertLength, ToPyChipError(CHIP_ERROR_NO_MEMORY)); + + ChipLogDetail(Controller, "Creating New Device Controller"); + + auto devCtrl = std::make_unique(); + VerifyOrReturnError(devCtrl != nullptr, ToPyChipError(CHIP_ERROR_NO_MEMORY)); + + Controller::SetupParams initParams; + initParams.pairingDelegate = &sPairingDelegate; + initParams.operationalCredentialsDelegate = &sPlaceholderOperationalCredentialsIssuer; + initParams.operationalKeypair = operationalKey; + initParams.controllerRCAC = ByteSpan(rcac, rcacLen); + initParams.controllerICAC = ByteSpan(icac, icacLen); + initParams.controllerNOC = ByteSpan(noc, nocLen); + initParams.enableServerInteractions = enableServerInteractions; + initParams.controllerVendorId = adminVendorId; + initParams.permitMultiControllerFabrics = true; + initParams.hasExternallyOwnedOperationalKeypair = true; + + CHIP_ERROR err = Controller::DeviceControllerFactory::GetInstance().SetupCommissioner(initParams, *devCtrl); + VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); + + // Setup IPK in Group Data Provider for controller after Commissioner init which sets-up the fabric table entry + uint8_t compressedFabricId[sizeof(uint64_t)] = { 0 }; + chip::MutableByteSpan compressedFabricIdSpan(compressedFabricId); + + err = devCtrl->GetCompressedFabricIdBytes(compressedFabricIdSpan); + VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); + + ChipLogProgress(Support, "Setting up group data for Fabric Index %u with Compressed Fabric ID:", + static_cast(devCtrl->GetFabricIndex())); + ChipLogByteSpan(Support, compressedFabricIdSpan); + + chip::ByteSpan fabricIpk = + (ipk == nullptr) ? chip::GroupTesting::DefaultIpkValue::GetDefaultIpk() : chip::ByteSpan(ipk, ipkLen); + err = + chip::Credentials::SetSingleIpkEpochKey(&sGroupDataProvider, devCtrl->GetFabricIndex(), fabricIpk, compressedFabricIdSpan); + VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); + + *outDevCtrl = devCtrl.release(); + + return ToPyChipError(CHIP_NO_ERROR); +} + +// TODO(#25214): Need clean up API PyChipError pychip_OpCreds_AllocateController(OpCredsContext * context, chip::Controller::DeviceCommissioner ** outDevCtrl, FabricId fabricId, chip::NodeId nodeId, chip::VendorId adminVendorId, const char * paaTrustStorePath, bool useTestCommissioner, - bool enableServerInteractions, CASEAuthTag * caseAuthTags, uint32_t caseAuthTagLen) + bool enableServerInteractions, CASEAuthTag * caseAuthTags, uint32_t caseAuthTagLen, + chip::python::pychip_P256Keypair * operationalKey) { + CHIP_ERROR err = CHIP_NO_ERROR; + ChipLogDetail(Controller, "Creating New Device Controller"); VerifyOrReturnError(context != nullptr, ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT)); @@ -393,8 +460,18 @@ PyChipError pychip_OpCreds_AllocateController(OpCredsContext * context, chip::Co SetDeviceAttestationVerifier(GetDefaultDACVerifier(testingRootStore)); chip::Crypto::P256Keypair ephemeralKey; - CHIP_ERROR err = ephemeralKey.Initialize(chip::Crypto::ECPKeyTarget::ECDSA); - VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); + chip::Crypto::P256Keypair * controllerKeyPair; + + if (operationalKey == nullptr) + { + err = ephemeralKey.Initialize(chip::Crypto::ECPKeyTarget::ECDSA); + VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); + controllerKeyPair = &ephemeralKey; + } + else + { + controllerKeyPair = operationalKey; + } chip::Platform::ScopedMemoryBuffer noc; ReturnErrorCodeIf(!noc.Alloc(Controller::kMaxCHIPDERCertLength), ToPyChipError(CHIP_ERROR_NO_MEMORY)); @@ -419,19 +496,21 @@ PyChipError pychip_OpCreds_AllocateController(OpCredsContext * context, chip::Co memcpy(catValues.values.data(), caseAuthTags, caseAuthTagLen * sizeof(CASEAuthTag)); - err = context->mAdapter->GenerateNOCChain(nodeId, fabricId, catValues, ephemeralKey.Pubkey(), rcacSpan, icacSpan, nocSpan); + err = + context->mAdapter->GenerateNOCChain(nodeId, fabricId, catValues, controllerKeyPair->Pubkey(), rcacSpan, icacSpan, nocSpan); VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); Controller::SetupParams initParams; - initParams.pairingDelegate = &sPairingDelegate; - initParams.operationalCredentialsDelegate = context->mAdapter.get(); - initParams.operationalKeypair = &ephemeralKey; - initParams.controllerRCAC = rcacSpan; - initParams.controllerICAC = icacSpan; - initParams.controllerNOC = nocSpan; - initParams.enableServerInteractions = enableServerInteractions; - initParams.controllerVendorId = adminVendorId; - initParams.permitMultiControllerFabrics = true; + initParams.pairingDelegate = &sPairingDelegate; + initParams.operationalCredentialsDelegate = context->mAdapter.get(); + initParams.operationalKeypair = controllerKeyPair; + initParams.controllerRCAC = rcacSpan; + initParams.controllerICAC = icacSpan; + initParams.controllerNOC = nocSpan; + initParams.enableServerInteractions = enableServerInteractions; + initParams.controllerVendorId = adminVendorId; + initParams.permitMultiControllerFabrics = true; + initParams.hasExternallyOwnedOperationalKeypair = operationalKey != nullptr; if (useTestCommissioner) { @@ -505,6 +584,22 @@ PyChipError pychip_DeviceController_DeleteDeviceController(chip::Controller::Dev return ToPyChipError(CHIP_NO_ERROR); } +PyChipError pychip_DeviceController_SetIpk(chip::Controller::DeviceCommissioner * devCtrl, const uint8_t * ipk, size_t ipkLen) +{ + VerifyOrReturnError(ipk != nullptr, ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT)); + + uint8_t compressedFabricId[sizeof(uint64_t)] = { 0 }; + chip::MutableByteSpan compressedFabricIdSpan(compressedFabricId); + + CHIP_ERROR err = devCtrl->GetCompressedFabricIdBytes(compressedFabricIdSpan); + VerifyOrReturnError(err == CHIP_NO_ERROR, ToPyChipError(err)); + + err = chip::Credentials::SetSingleIpkEpochKey(&sGroupDataProvider, devCtrl->GetFabricIndex(), ByteSpan(ipk, ipkLen), + compressedFabricIdSpan); + + return ToPyChipError(err); +} + bool pychip_TestCommissionerUsed() { return sTestCommissioner.GetTestCommissionerUsed(); diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py index 14419e091ac006..3626dac0027e76 100644 --- a/src/controller/python/chip/ChipDeviceCtrl.py +++ b/src/controller/python/chip/ChipDeviceCtrl.py @@ -51,6 +51,7 @@ from .clusters import Command as ClusterCommand from .clusters import Objects as GeneratedObjects from .clusters.CHIPClusters import * +from .crypto import p256keypair from .exceptions import * from .interaction_model import InteractionModelError from .interaction_model import delegate as im @@ -203,12 +204,10 @@ def numTotalSessions(self) -> int: DiscoveryFilterType = discovery.FilterType -class ChipDeviceController(): +class ChipDeviceControllerBase(): activeList = set() - def __init__(self, opCredsContext: ctypes.c_void_p, fabricId: int, nodeId: int, adminVendorId: int, - catTags: typing.List[int] = [], paaTrustStorePath: str = "", useTestCommissioner: bool = False, - fabricAdmin: FabricAdmin.FabricAdmin = None, name: str = None): + def __init__(self, name: str = ''): self.state = DCState.NOT_INITIALIZED self.devCtrl = None self._ChipStack = builtins.chipStack @@ -216,39 +215,15 @@ def __init__(self, opCredsContext: ctypes.c_void_p, fabricId: int, nodeId: int, self._InitLib() - self._dmLib.pychip_DeviceController_SetIssueNOCChainCallbackPythonCallback(_IssueNOCChainCallbackPythonCallback) - devCtrl = c_void_p(None) - c_catTags = (c_uint32 * len(catTags))() - - for i, item in enumerate(catTags): - c_catTags[i] = item - - self._dmLib.pychip_OpCreds_AllocateController.argtypes = [c_void_p, POINTER( - c_void_p), c_uint64, c_uint64, c_uint16, c_char_p, c_bool, c_bool, POINTER(c_uint32), c_uint32] - self._dmLib.pychip_OpCreds_AllocateController.restype = PyChipError - - # TODO(erjiaqing@): Figure out how to control enableServerInteractions for a single device controller (node) - self._ChipStack.Call( - lambda: self._dmLib.pychip_OpCreds_AllocateController(c_void_p( - opCredsContext), pointer(devCtrl), fabricId, nodeId, adminVendorId, c_char_p(None if len(paaTrustStorePath) == 0 else str.encode(paaTrustStorePath)), useTestCommissioner, self._ChipStack.enableServerInteractions, c_catTags, len(catTags)) - ).raise_on_error() - self.devCtrl = devCtrl - self._fabricAdmin = fabricAdmin - self._fabricId = fabricId - self._nodeId = nodeId - self._caIndex = fabricAdmin.caIndex - - if name is None: - self._name = "caIndex(%x)/fabricId(0x%016X)/nodeId(0x%016X)" % (fabricAdmin.caIndex, fabricId, nodeId) - else: - self._name = name + self.name = name self._Cluster = ChipClusters(builtins.chipStack) self._Cluster.InitLib(self._dmLib) + def _set_dev_ctrl(self, devCtrl): def HandleCommissioningComplete(nodeid, err): if err.is_success: print("Commissioning complete") @@ -292,6 +267,8 @@ def HandlePASEEstablishmentComplete(err: PyChipError): if not err.is_success: HandleCommissioningComplete(0, err) + self.devCtrl = devCtrl + self.cbHandlePASEEstablishmentCompleteFunct = _DevicePairingDelegate_OnPairingCompleteFunct( HandlePASEEstablishmentComplete) self._dmLib.pychip_ScriptDevicePairingDelegate_SetKeyExchangeCallback( @@ -312,9 +289,11 @@ def HandlePASEEstablishmentComplete(err: PyChipError): # Validate FabricID/NodeID followed from NOC Chain self._fabricId = self.GetFabricIdInternal() - assert self._fabricId == fabricId self._nodeId = self.GetNodeIdInternal() - assert self._nodeId == nodeId + + def _finish_init(self): + self.state = DCState.IDLE + self._isActive = True ChipDeviceController.activeList.add(self) @@ -330,10 +309,6 @@ def nodeId(self) -> int: def fabricId(self) -> int: return self._fabricId - @property - def caIndex(self) -> int: - return self._caIndex - @property def name(self) -> str: return self._name @@ -458,20 +433,6 @@ def EstablishPASESessionIP(self, ipaddr: str, setupPinCode: int, nodeid: int): self.devCtrl, ipaddr.encode("utf-8"), setupPinCode, nodeid) ) - def Commission(self, nodeid): - self.CheckIsActive() - self._ChipStack.commissioningCompleteEvent.clear() - self.state = DCState.COMMISSIONING - - self._ChipStack.CallAsync( - lambda: self._dmLib.pychip_DeviceController_Commission( - self.devCtrl, nodeid) - ) - if not self._ChipStack.commissioningCompleteEvent.isSet(): - # Error 50 is a timeout - return False - return self._ChipStack.commissioningEventRes == 0 - def GetTestCommissionerUsed(self): return self._ChipStack.Call( lambda: self._dmLib.pychip_TestCommissionerUsed() @@ -500,116 +461,11 @@ def CheckTestCommissionerCallbacks(self): def CheckTestCommissionerPaseConnection(self, nodeid): return self._dmLib.pychip_TestPaseConnection(nodeid) - def CommissionOnNetwork(self, nodeId: int, setupPinCode: int, filterType: DiscoveryFilterType = DiscoveryFilterType.NONE, filter: typing.Any = None): - ''' - Does the routine for OnNetworkCommissioning, with a filter for mDNS discovery. - Supported filters are: - - DiscoveryFilterType.NONE - DiscoveryFilterType.SHORT_DISCRIMINATOR - DiscoveryFilterType.LONG_DISCRIMINATOR - DiscoveryFilterType.VENDOR_ID - DiscoveryFilterType.DEVICE_TYPE - DiscoveryFilterType.COMMISSIONING_MODE - DiscoveryFilterType.INSTANCE_NAME - DiscoveryFilterType.COMMISSIONER - DiscoveryFilterType.COMPRESSED_FABRIC_ID - - The filter can be an integer, a string or None depending on the actual type of selected filter. - ''' - self.CheckIsActive() - - # IP connection will run through full commissioning, so we need to wait - # for the commissioning complete event, not just any callback. - self.state = DCState.COMMISSIONING - - # Convert numerical filters to string for passing down to binding. - if isinstance(filter, int): - filter = str(filter) - - self._ChipStack.commissioningCompleteEvent.clear() - - self._ChipStack.CallAsync( - lambda: self._dmLib.pychip_DeviceController_OnNetworkCommission( - self.devCtrl, nodeId, setupPinCode, int(filterType), str(filter).encode("utf-8") + b"\x00" if filter is not None else None) - ) - if not self._ChipStack.commissioningCompleteEvent.isSet(): - # Error 50 is a timeout - return False, -1 - return self._ChipStack.commissioningEventRes == 0, self._ChipStack.commissioningEventRes - - def CommissionWithCode(self, setupPayload: str, nodeid: int): - self.CheckIsActive() - - setupPayload = setupPayload.encode() + b'\0' - - # IP connection will run through full commissioning, so we need to wait - # for the commissioning complete event, not just any callback. - self.state = DCState.COMMISSIONING - - self._ChipStack.commissioningCompleteEvent.clear() - - self._ChipStack.CallAsync( - lambda: self._dmLib.pychip_DeviceController_ConnectWithCode( - self.devCtrl, setupPayload, nodeid) - ) - if not self._ChipStack.commissioningCompleteEvent.isSet(): - # Error 50 is a timeout - return False - return self._ChipStack.commissioningEventRes == 0 - - def CommissionIP(self, ipaddr: str, setupPinCode: int, nodeid: int): - """ DEPRECATED, DO NOT USE! Use `CommissionOnNetwork` or `CommissionWithCode` """ - self.CheckIsActive() - - # IP connection will run through full commissioning, so we need to wait - # for the commissioning complete event, not just any callback. - self.state = DCState.COMMISSIONING - - self._ChipStack.commissioningCompleteEvent.clear() - - self._ChipStack.CallAsync( - lambda: self._dmLib.pychip_DeviceController_ConnectIP( - self.devCtrl, ipaddr.encode("utf-8"), setupPinCode, nodeid) - ) - if not self._ChipStack.commissioningCompleteEvent.isSet(): - # Error 50 is a timeout - return False - return self._ChipStack.commissioningEventRes == 0 - def NOCChainCallback(self, nocChain): self._ChipStack.callbackRes = nocChain self._ChipStack.completeEvent.set() return - def CommissionThread(self, discriminator, setupPinCode, nodeId, threadOperationalDataset: bytes): - ''' Commissions a Thread device over BLE - ''' - self.SetThreadOperationalDataset(threadOperationalDataset) - return self.ConnectBLE(discriminator, setupPinCode, nodeId) - - def CommissionWiFi(self, discriminator, setupPinCode, nodeId, ssid: str, credentials: str): - ''' Commissions a WiFi device over BLE - ''' - self.SetWiFiCredentials(ssid, credentials) - return self.ConnectBLE(discriminator, setupPinCode, nodeId) - - def SetWiFiCredentials(self, ssid: str, credentials: str): - self.CheckIsActive() - - self._ChipStack.Call( - lambda: self._dmLib.pychip_DeviceController_SetWiFiCredentials( - ssid.encode("utf-8"), credentials.encode("utf-8")) - ).raise_on_error() - - def SetThreadOperationalDataset(self, threadOperationalDataset): - self.CheckIsActive() - - self._ChipStack.Call( - lambda: self._dmLib.pychip_DeviceController_SetThreadOperationalDataset( - threadOperationalDataset, len(threadOperationalDataset)) - ).raise_on_error() - def ResolveNode(self, nodeid): self.CheckIsActive() @@ -1319,15 +1175,10 @@ def SetBlockingCB(self, blockingCB): self._ChipStack.blockingCB = blockingCB - def IssueNOCChain(self, csr: Clusters.OperationalCredentials.Commands.CSRResponse, nodeId: int): - """Issue an NOC chain using the associated OperationalCredentialsDelegate. - The NOC chain will be provided in TLV cert format.""" - self.CheckIsActive() - - return self._ChipStack.CallAsync( - lambda: self._dmLib.pychip_DeviceController_IssueNOCChain( - self.devCtrl, py_object(self), csr.NOCSRElements, len(csr.NOCSRElements), nodeId) - ) + def SetIpk(self, ipk: bytes): + self._ChipStack.Call( + lambda: self._dmLib.pychip_DeviceController_SetIpk(self.devCtrl, ipk, len(ipk)) + ).raise_on_error() def InitGroupTestingData(self): """Populates the Device Controller's GroupDataProvider with known test group info and keys.""" @@ -1520,3 +1371,236 @@ def _InitLib(self): self._dmLib.pychip_DeviceController_GetLogFilter = [None] self._dmLib.pychip_DeviceController_GetLogFilter = c_uint8 + + self._dmLib.pychip_OpCreds_AllocateController.argtypes = [c_void_p, POINTER( + c_void_p), c_uint64, c_uint64, c_uint16, c_char_p, c_bool, c_bool, POINTER(c_uint32), c_uint32, c_void_p] + self._dmLib.pychip_OpCreds_AllocateController.restype = PyChipError + + self._dmLib.pychip_OpCreds_AllocateControllerForPythonCommissioningFLow.argtypes = [ + POINTER(c_void_p), c_void_p, POINTER(c_char), c_uint32, POINTER(c_char), c_uint32, POINTER(c_char), c_uint32, POINTER(c_char), c_uint32, c_uint16, c_bool] + self._dmLib.pychip_OpCreds_AllocateControllerForPythonCommissioningFLow.restype = PyChipError + + self._dmLib.pychip_DeviceController_SetIpk.argtypes = [c_void_p, POINTER(c_char), c_size_t] + self._dmLib.pychip_DeviceController_SetIpk.restype = PyChipError + + +class ChipDeviceController(ChipDeviceControllerBase): + ''' The ChipDeviceCommissioner binding, named as ChipDeviceController + + TODO: This class contains DEPRECATED functions, we should update the test scripts to avoid the usage of those functions. + ''' + + def __init__(self, opCredsContext: ctypes.c_void_p, fabricId: int, nodeId: int, adminVendorId: int, catTags: typing.List[int] = [], paaTrustStorePath: str = "", useTestCommissioner: bool = False, fabricAdmin: FabricAdmin = None, name: str = None, keypair: p256keypair.P256Keypair = None): + super().__init__( + name or + f"caIndex({fabricAdmin.caIndex:x})/fabricId(0x{fabricId:016X})/nodeId(0x{nodeId:016X})" + ) + + self._dmLib.pychip_DeviceController_SetIssueNOCChainCallbackPythonCallback(_IssueNOCChainCallbackPythonCallback) + + devCtrl = c_void_p(None) + + c_catTags = (c_uint32 * len(catTags))() + + for i, item in enumerate(catTags): + c_catTags[i] = item + + # TODO(erjiaqing@): Figure out how to control enableServerInteractions for a single device controller (node) + self._externalKeyPair = keypair + self._ChipStack.Call( + lambda: self._dmLib.pychip_OpCreds_AllocateController(c_void_p( + opCredsContext), pointer(devCtrl), fabricId, nodeId, adminVendorId, c_char_p(None if len(paaTrustStorePath) == 0 else str.encode(paaTrustStorePath)), useTestCommissioner, self._ChipStack.enableServerInteractions, c_catTags, len(catTags), None if keypair is None else keypair.native_object) + ).raise_on_error() + + self._fabricAdmin = fabricAdmin + self._fabricId = fabricId + self._nodeId = nodeId + self._caIndex = fabricAdmin.caIndex + + self._set_dev_ctrl(devCtrl=devCtrl) + + self._finish_init() + + assert self._fabricId == fabricId + assert self._nodeId == nodeId + + @property + def caIndex(self) -> int: + return self._caIndex + + @property + def fabricAdmin(self) -> FabricAdmin: + return self._fabricAdmin + + def Commission(self, nodeid) -> bool: + ''' + Start the auto-commissioning process on a node after establishing a PASE connection. + This function is intended to be used in conjunction with `EstablishPASESessionBLE` or + `EstablishPASESessionIP`. It can be called either before or after the DevicePairingDelegate + receives the OnPairingComplete call. Commissioners that want to perform simple + auto-commissioning should use the supplied "PairDevice" functions above, which will + establish the PASE connection and commission automatically. + + Return: + bool: True if successful, False otherwise. + ''' + self.CheckIsActive() + self._ChipStack.commissioningCompleteEvent.clear() + self.state = DCState.COMMISSIONING + + self._ChipStack.CallAsync( + lambda: self._dmLib.pychip_DeviceController_Commission( + self.devCtrl, nodeid) + ) + return (self._ChipStack.commissioningCompleteEvent.isSet() and (self._ChipStack.commissioningEventRes == 0)) + + def CommissionThread(self, discriminator, setupPinCode, nodeId, threadOperationalDataset: bytes): + ''' Commissions a Thread device over BLE + ''' + self.SetThreadOperationalDataset(threadOperationalDataset) + return self.ConnectBLE(discriminator, setupPinCode, nodeId) + + def CommissionWiFi(self, discriminator, setupPinCode, nodeId, ssid: str, credentials: str): + ''' Commissions a WiFi device over BLE + ''' + self.SetWiFiCredentials(ssid, credentials) + return self.ConnectBLE(discriminator, setupPinCode, nodeId) + + def SetWiFiCredentials(self, ssid: str, credentials: str): + self.CheckIsActive() + + self._ChipStack.Call( + lambda: self._dmLib.pychip_DeviceController_SetWiFiCredentials( + ssid.encode("utf-8"), credentials.encode("utf-8")) + ).raise_on_error() + + def SetThreadOperationalDataset(self, threadOperationalDataset): + self.CheckIsActive() + + self._ChipStack.Call( + lambda: self._dmLib.pychip_DeviceController_SetThreadOperationalDataset( + threadOperationalDataset, len(threadOperationalDataset)) + ).raise_on_error() + + def CommissionOnNetwork(self, nodeId: int, setupPinCode: int, filterType: DiscoveryFilterType = DiscoveryFilterType.NONE, filter: typing.Any = None): + ''' + Does the routine for OnNetworkCommissioning, with a filter for mDNS discovery. + Supported filters are: + + DiscoveryFilterType.NONE + DiscoveryFilterType.SHORT_DISCRIMINATOR + DiscoveryFilterType.LONG_DISCRIMINATOR + DiscoveryFilterType.VENDOR_ID + DiscoveryFilterType.DEVICE_TYPE + DiscoveryFilterType.COMMISSIONING_MODE + DiscoveryFilterType.INSTANCE_NAME + DiscoveryFilterType.COMMISSIONER + DiscoveryFilterType.COMPRESSED_FABRIC_ID + + The filter can be an integer, a string or None depending on the actual type of selected filter. + ''' + self.CheckIsActive() + + # IP connection will run through full commissioning, so we need to wait + # for the commissioning complete event, not just any callback. + self.state = DCState.COMMISSIONING + + # Convert numerical filters to string for passing down to binding. + if isinstance(filter, int): + filter = str(filter) + + self._ChipStack.commissioningCompleteEvent.clear() + + self._ChipStack.CallAsync( + lambda: self._dmLib.pychip_DeviceController_OnNetworkCommission( + self.devCtrl, nodeId, setupPinCode, int(filterType), str(filter).encode("utf-8") + b"\x00" if filter is not None else None) + ) + if not self._ChipStack.commissioningCompleteEvent.isSet(): + return False, -1 + return self._ChipStack.commissioningEventRes == 0, self._ChipStack.commissioningEventRes + + def CommissionWithCode(self, setupPayload: str, nodeid: int): + self.CheckIsActive() + + setupPayload = setupPayload.encode() + b'\0' + + # IP connection will run through full commissioning, so we need to wait + # for the commissioning complete event, not just any callback. + self.state = DCState.COMMISSIONING + + self._ChipStack.commissioningCompleteEvent.clear() + + self._ChipStack.CallAsync( + lambda: self._dmLib.pychip_DeviceController_ConnectWithCode( + self.devCtrl, setupPayload, nodeid) + ) + if not self._ChipStack.commissioningCompleteEvent.isSet(): + return False + return self._ChipStack.commissioningEventRes == 0 + + def CommissionIP(self, ipaddr: str, setupPinCode: int, nodeid: int): + """ DEPRECATED, DO NOT USE! Use `CommissionOnNetwork` or `CommissionWithCode` """ + self.CheckIsActive() + + # IP connection will run through full commissioning, so we need to wait + # for the commissioning complete event, not just any callback. + self.state = DCState.COMMISSIONING + + self._ChipStack.commissioningCompleteEvent.clear() + + self._ChipStack.CallAsync( + lambda: self._dmLib.pychip_DeviceController_ConnectIP( + self.devCtrl, ipaddr.encode("utf-8"), setupPinCode, nodeid) + ) + if not self._ChipStack.commissioningCompleteEvent.isSet(): + return False + return self._ChipStack.commissioningEventRes == 0 + + def IssueNOCChain(self, csr: Clusters.OperationalCredentials.Commands.CSRResponse, nodeId: int): + """Issue an NOC chain using the associated OperationalCredentialsDelegate. + The NOC chain will be provided in TLV cert format.""" + self.CheckIsActive() + + return self._ChipStack.CallAsync( + lambda: self._dmLib.pychip_DeviceController_IssueNOCChain( + self.devCtrl, py_object(self), csr.NOCSRElements, len(csr.NOCSRElements), nodeId) + ) + + +class BareChipDeviceController(ChipDeviceControllerBase): + ''' A bare device controller without AutoCommissioner support. + ''' + + def __init__(self, operationalKey: p256keypair.P256Keypair, noc: bytes, icac: typing.Union[bytes, None], rcac: bytes, ipk: typing.Union[bytes, None], adminVendorId: int, name: str = None): + '''Creates a controller without autocommissioner. + + The allocated controller uses the noc, icac, rcac and ipk instead of the default, + random generated certificates / keys. Which is suitable for creating a controller + for manually signing certificates for testing. + + Args: + operationalKey: A P256Keypair object for the operational key of the controller. + noc: The NOC for the controller, in bytes. + icac: The optional ICAC for the controller. + rcac: The RCAC for the controller. + ipk: The optional IPK for the controller, when None is provided, the defaultIpk + will be used. + adminVendorId: The adminVendorId of the controller. + name: The name of the controller, for debugging use only. + ''' + super().__init__(name or f"ctrl(v/{adminVendorId})") + + devCtrl = c_void_p(None) + + # Device should hold a reference to the key to avoid it being GC-ed. + self._externalKeyPair = operationalKey + nativeKey = operationalKey.create_native_object() + + self._ChipStack.Call( + lambda: self._dmLib.pychip_OpCreds_AllocateControllerForPythonCommissioningFLow( + c_void_p(devCtrl), nativeKey, noc, len(noc), icac, len(icac) if icac else 0, rcac, len(rcac), ipk, len(ipk) if ipk else 0, adminVendorId, self._ChipStack.enableServerInteractions) + ).raise_on_error() + + self._set_dev_ctrl(devCtrl) + + self._finish_init() diff --git a/src/controller/python/chip/FabricAdmin.py b/src/controller/python/chip/FabricAdmin.py index fbd4bc2165d11f..873344d540a4ae 100644 --- a/src/controller/python/chip/FabricAdmin.py +++ b/src/controller/python/chip/FabricAdmin.py @@ -23,6 +23,7 @@ from typing import * from chip import CertificateAuthority, ChipDeviceCtrl +from chip.crypto import p256keypair from chip.native import GetLibraryHandle @@ -65,7 +66,7 @@ def __init__(self, certificateAuthority: CertificateAuthority.CertificateAuthori self._isActive = True self._activeControllers = [] - def NewController(self, nodeId: int = None, paaTrustStorePath: str = "", useTestCommissioner: bool = False, catTags: List[int] = []): + def NewController(self, nodeId: int = None, paaTrustStorePath: str = "", useTestCommissioner: bool = False, catTags: List[int] = [], keypair: p256keypair.P256Keypair = None): ''' Create a new chip.ChipDeviceCtrl.ChipDeviceController instance on this fabric. When vending ChipDeviceController instances on a given fabric, each controller instance @@ -104,7 +105,8 @@ def NewController(self, nodeId: int = None, paaTrustStorePath: str = "", useTest paaTrustStorePath=paaTrustStorePath, useTestCommissioner=useTestCommissioner, fabricAdmin=self, - catTags=catTags) + catTags=catTags, + keypair=keypair) self._activeControllers.append(controller) return controller diff --git a/src/controller/python/chip/commissioning/PlaceholderOperationalCredentialsIssuer.h b/src/controller/python/chip/commissioning/PlaceholderOperationalCredentialsIssuer.h new file mode 100644 index 00000000000000..11bfcd69a5d227 --- /dev/null +++ b/src/controller/python/chip/commissioning/PlaceholderOperationalCredentialsIssuer.h @@ -0,0 +1,62 @@ +/* + * + * Copyright (c) 2021-2022 Project CHIP Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file + * This file contains class definition of an example operational certificate + * issuer for CHIP devices. The class can be used as a guideline on how to + * construct your own certificate issuer. It can also be used in tests and tools + * if a specific signing authority is not required. + * + * NOTE: This class stores the encryption key in clear storage. This is not suited + * for production use. This should only be used in test tools. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace chip { +namespace Python { + +class DLL_EXPORT PlaceholderOperationalCredentialsIssuer : public Controller::OperationalCredentialsDelegate +{ +public: + PlaceholderOperationalCredentialsIssuer() {} + ~PlaceholderOperationalCredentialsIssuer() override {} + + CHIP_ERROR GenerateNOCChain(const ByteSpan & csrElements, const ByteSpan & csrNonce, const ByteSpan & attestationSignature, + const ByteSpan & attestationChallenge, const ByteSpan & DAC, const ByteSpan & PAI, + Callback::Callback * onCompletion) override + { + return CHIP_ERROR_NOT_IMPLEMENTED; + } + + void SetNodeIdForNextNOCRequest(NodeId nodeId) override {} + + void SetFabricIdForNextNOCRequest(FabricId fabricId) override {} +}; + +} // namespace Python +} // namespace chip diff --git a/src/controller/python/chip/commissioning/__init__.py b/src/controller/python/chip/commissioning/__init__.py new file mode 100644 index 00000000000000..2bf37ba74e2edf --- /dev/null +++ b/src/controller/python/chip/commissioning/__init__.py @@ -0,0 +1,140 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import abc +import dataclasses +import enum +import os +from typing import Set, Tuple, Union + +ROOT_ENDPOINT_ID = 0 + + +@dataclasses.dataclass +class CommissioneeInfo: + endpoints: Set[int] + is_thread_device: bool = False + is_wifi_device: bool = False + is_ethernet_device: bool = False + + +class RegulatoryLocationType(enum.IntEnum): + INDOOR = 0 + OUTDOOR = 1 + INDOOR_OUTDOOR = 2 + + +@dataclasses.dataclass +class RegulatoryConfig: + location_type: RegulatoryLocationType + country_code: str + + +@dataclasses.dataclass +class PaseParameters: + setup_pin: int + temporary_nodeid: int + + +@dataclasses.dataclass +class PaseOverBLEParameters(PaseParameters): + discriminator: int + + def __str__(self): + return f"BLE:0x{self.discriminator:03x}" + + +@dataclasses.dataclass +class PaseOverIPParameters(PaseParameters): + long_discriminator: int + + def __str__(self): + return f"Discriminator:0x{self.long_discriminator:03x}" + + +@dataclasses.dataclass +class WiFiCredentials: + ssid: bytes + passphrase: bytes + + +@dataclasses.dataclass +class Parameters: + pase_param: Union[PaseOverBLEParameters, PaseOverIPParameters] + regulatory_config: RegulatoryConfig + fabric_label: str + commissionee_info: CommissioneeInfo + wifi_credentials: WiFiCredentials + thread_credentials: bytes + failsafe_expiry_length_seconds: int = 600 + + +class NetworkCommissioningFeatureMap(enum.IntEnum): + WIFI_NETWORK_FEATURE_MAP = 1 + THREAD_NETWORK_FEATURE_MAP = 2 + + +class CommissionFailure(Exception): + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return f"CommissionFailure({self.msg})" + + +@dataclasses.dataclass +class GetCommissioneeCredentialsRequest: + dac: bytes + pai: bytes + attestation_signature: bytes + attestation_nonce: bytes + attestation_elements: bytes + csr_signature: bytes + csr_nonce: bytes + csr_elements: bytes + vendor_id: int + product_id: int + + +@dataclasses.dataclass +class GetCommissioneeCredentialsResponse: + rcac: bytes + noc: bytes + icac: bytes + ipk: bytes + case_admin_node: int + admin_vendor_id: int + node_id: int = None + fabric_id: int = None + + +class CredentialProvider: + async def get_commissionee_nonces(self) -> Tuple[bytes, bytes]: + ''' Returns the `attestation_nonce` and `csr_nonce` for the commissionee. + ''' + return os.urandom(32), os.urandom(32) + + @abc.abstractmethod + async def get_commissionee_credentials(self, request: GetCommissioneeCredentialsRequest) -> GetCommissioneeCredentialsResponse: + ''' Returns certifications and infomations for the commissioning. + ''' + raise NotImplementedError() + + +class ExampleCredentialProvider: + async def get_commissionee_credentials(self, request: GetCommissioneeCredentialsRequest) -> GetCommissioneeCredentialsResponse: + pass diff --git a/src/controller/python/chip/commissioning/commissioning_flow_blocks.py b/src/controller/python/chip/commissioning/commissioning_flow_blocks.py new file mode 100644 index 00000000000000..986064af8b6e9e --- /dev/null +++ b/src/controller/python/chip/commissioning/commissioning_flow_blocks.py @@ -0,0 +1,246 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import base64 +import logging + +import chip.credentials.cert +import chip.crypto.fabric +from chip import ChipDeviceCtrl +from chip import clusters as Clusters +from chip import commissioning +from cryptography import x509 +from cryptography.hazmat.primitives import serialization + + +class CommissioningFlowBlocks: + def __init__(self, devCtrl: ChipDeviceCtrl.ChipDeviceControllerBase, credential_provider: commissioning.CredentialProvider, logger: logging.Logger): + self._devCtrl = devCtrl + self._logger = logger + self._credential_provider = credential_provider + + async def arm_failsafe(self, node_id: int, duration_seconds: int = 180): + response = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.GeneralCommissioning.Commands.ArmFailSafe( + expiryLengthSeconds=duration_seconds + )) + if response.errorCode != 0: + raise commissioning.CommissionFailure(repr(response)) + + async def operational_credentials_commissioning(self, parameter: commissioning.Parameters, node_id: int): + self._logger.info("Getting Remote Device Info") + device_info = (await self._devCtrl.ReadAttribute(node_id, [ + (commissioning.ROOT_ENDPOINT_ID, Clusters.BasicInformation.Attributes.VendorID), + (commissioning.ROOT_ENDPOINT_ID, Clusters.BasicInformation.Attributes.ProductID)], returnClusterObject=True))[commissioning.ROOT_ENDPOINT_ID][Clusters.BasicInformation] + + self._logger.info("Getting AttestationNonce") + attestation_nonce = await self._credential_provider.get_attestation_nonce() + + self._logger.info("Getting CSR Nonce") + csr_nonce = await self._credential_provider.get_csr_nonce() + + self._logger.info("Sending AttestationRequest") + try: + attestation_elements = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.AttestationRequest( + attestationNonce=attestation_nonce + )) + except Exception as ex: + raise commissioning.CommissionFailure(f"Failed to get AttestationElements: {ex}") + + self._logger.info("Getting CertificateChain - DAC") + # Failures are exceptions + try: + dac = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.CertificateChainRequest( + certificateType=1 + )) + except Exception as ex: + raise commissioning.CommissionFailure(f"Failed to get DAC: {ex}") + + self._logger.info("Getting CertificateChain - PAI") + try: + pai = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.CertificateChainRequest( + certificateType=2 + )) + except Exception as ex: + raise commissioning.CommissionFailure(f"Failed to get PAI: {ex}") + + self._logger.info("Getting OpCSRRequest") + try: + csr = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.CSRRequest( + CSRNonce=csr_nonce + )) + except Exception as ex: + raise commissioning.CommissionFailure(f"Failed to get OpCSRRequest: {ex}") + + self._logger.info("Getting device certificate") + commissionee_credentials = await self._credential_provider.get_commissionee_credentials( + commissioning.GetCommissioneeCredentialsRequest( + dac=dac, pai=pai, + attestation_nonce=attestation_nonce, + attestation_elements=attestation_elements.attestationElements, + attestation_signature=attestation_elements.attestationSignature, + csr_nonce=csr_nonce, + csr_elements=csr.NOCSRElements, + csr_signature=csr.attestationSignature, + vendor_id=device_info.vendorID, + product_id=device_info.productID)) + + self._logger.info("Adding Trusted Root Certificate") + try: + response = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.AddTrustedRootCertificate( + rootCACertificate=commissionee_credentials.rcac + )) + except Exception as ex: + raise commissioning.CommissionFailure(f"Failed to add Root Certificate: {ex}") + + try: + x509_rcac = x509.load_pem_x509_certificate( + b'''-----BEGIN CERTIFICATE-----\n''' + + base64.b64encode(chip.credentials.cert.convert_chip_cert_to_x509_cert(commissionee_credentials.rcac)) + + b'''\n-----END CERTIFICATE-----''') + root_public_key = x509_rcac.public_key().public_bytes(serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint) + + x509_noc = x509.load_pem_x509_certificate( + b'''-----BEGIN CERTIFICATE-----\n''' + + base64.b64encode(chip.credentials.cert.convert_chip_cert_to_x509_cert(commissionee_credentials.noc)) + + b'''\n-----END CERTIFICATE-----''') + + for subject in x509_noc.subject: + if subject.oid.dotted_string == '1.3.6.1.4.1.37244.1.1': + cert_fabric_id = int(subject.value, 16) + elif subject.oid.dotted_string == '1.3.6.1.4.1.37244.1.5': + cert_node_id = int(subject.value, 16) + + if cert_fabric_id != commissionee_credentials.fabric_id: + self._logger.warning("Fabric ID in certificate does not match the fabric id in commissionee credentials struct.") + if cert_node_id != commissionee_credentials.node_id: + self._logger.warning("Node ID in certificate does not match the node id in commissionee credentials struct.") + + compressed_fabric_id = chip.crypto.fabric.generate_compressed_fabric_id(root_public_key, cert_fabric_id) + + except Exception: + self._logger.exception("The certificate should be a valid CHIP Certificate, but failed to parse it") + raise + + self._logger.info( + f"Commissioning FabricID: {cert_fabric_id:016X} " + f"Compressed FabricID: {compressed_fabric_id:016X} " + f"Node ID: {cert_node_id:016X}") + + self._logger.info("Adding Operational Certificate") + response = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.AddNOC( + NOCValue=commissionee_credentials.noc, + ICACValue=commissionee_credentials.icac, + IPKValue=commissionee_credentials.ipk, + caseAdminSubject=commissionee_credentials.case_admin_node, + adminVendorId=commissionee_credentials.admin_vendor_id + )) + if response.statusCode != 0: + raise commissioning.CommissionFailure(repr(response)) + + self._logger.info("Update controller IPK") + self._devCtrl.SetIpk(commissionee_credentials.ipk) + + self._logger.info("Setting fabric label") + response = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.OperationalCredentials.Commands.UpdateFabricLabel( + label=parameter.fabric_label + )) + if response.statusCode != 0: + raise commissioning.CommissionFailure(repr(response)) + + return commissionee_credentials.node_id + + async def network_commissioning_thread(self, parameter: commissioning.Parameters, node_id: int): + if not parameter.thread_credentials: + raise TypeError("The device requires a Thread network dataset") + + self._logger.info("Adding Thread network") + response = await self._devCtrl.SendCommand(nodeid=node_id, endpoint=commissioning.ROOT_ENDPOINT_ID, payload=Clusters.NetworkCommissioning.Commands.AddOrUpdateThreadNetwork( + operationalDataset=parameter.thread_credentials)) + if response.networkingStatus != Clusters.NetworkCommissioning.Enums.NetworkCommissioningStatus.kSuccess: + raise commissioning.CommissionFailure(f"Unexpected result for adding network: {response.networkingStatus}") + + network_list = (await self._devCtrl.ReadAttribute(nodeid=node_id, attributes=[(commissioning.ROOT_ENDPOINT_ID, Clusters.NetworkCommissioning.Attributes.Networks)], returnClusterObject=True))[commissioning.ROOT_ENDPOINT_ID][Clusters.NetworkCommissioning].networks + network_id = network_list[response.networkIndex].networkID + + self._logger.info("Enabling Thread network") + response = await self._devCtrl.SendCommand(nodeid=node_id, endpoint=commissioning.ROOT_ENDPOINT_ID, payload=Clusters.NetworkCommissioning.Commands.ConnectNetwork(networkID=network_id), interactionTimeoutMs=self._devCtrl.ComputeRoundTripTimeout(node_id, upperLayerProcessingTimeoutMs=30000)) + if response.networkingStatus != Clusters.NetworkCommissioning.Enums.NetworkCommissioningStatus.kSuccess: + raise commissioning.CommissionFailure(f"Unexpected result for enabling network: {response.networkingStatus}") + + self._logger.info("Thread network commissioning finished") + + async def network_commissioning_wifi(self, parameter: commissioning.Parameters, node_id: int): + if not parameter.wifi_credentials: + raise TypeError("The device requires WiFi credentials") + + self._logger.info("Adding WiFi network") + response = await self._devCtrl.SendCommand(nodeid=node_id, endpoint=commissioning.ROOT_ENDPOINT_ID, payload=Clusters.NetworkCommissioning.Commands.AddOrUpdateWiFiNetwork(ssid=parameter.wifi_credentials.ssid, credentials=parameter.wifi_credentials.passphrase)) + if response.networkingStatus != Clusters.NetworkCommissioning.Enums.NetworkCommissioningStatus.kSuccess: + raise commissioning.CommissionFailure(f"Unexpected result for adding network: {response.networkingStatus}") + + network_list = (await self._devCtrl.ReadAttribute(nodeid=node_id, attributes=[(commissioning.ROOT_ENDPOINT_ID, Clusters.NetworkCommissioning.Attributes.Networks)], returnClusterObject=True))[commissioning.ROOT_ENDPOINT_ID][Clusters.NetworkCommissioning].networks + network_id = network_list[response.networkIndex].networkID + + self._logger.info("Enabling WiFi network") + response = await self._devCtrl.SendCommand(nodeid=node_id, endpoint=commissioning.ROOT_ENDPOINT_ID, payload=Clusters.NetworkCommissioning.Commands.ConnectNetwork(networkID=network_id), interactionTimeoutMs=self._devCtrl.ComputeRoundTripTimeout(node_id, upperLayerProcessingTimeoutMs=30000)) + if response.networkingStatus != Clusters.NetworkCommissioning.Enums.NetworkCommissioningStatus.kSuccess: + raise commissioning.CommissionFailure(f"Unexpected result for enabling network: {response.networkingStatus}") + + self._logger.info("WiFi network commissioning finished") + + async def network_commissioning(self, parameter: commissioning.Parameters, node_id: int): + clusters = await self._devCtrl.ReadAttribute(nodeid=node_id, attributes=[(Clusters.Descriptor.Attributes.ServerList)], returnClusterObject=True) + if Clusters.NetworkCommissioning.id not in clusters[commissioning.ROOT_ENDPOINT_ID][Clusters.Descriptor].serverList: + self._logger.info( + f"Network commissioning cluster {commissioning.ROOT_ENDPOINT_ID} is not enabled on this device.") + return + + network_commissioning_cluster_state = (await self._devCtrl.ReadAttribute( + nodeid=node_id, + attributes=[(commissioning.ROOT_ENDPOINT_ID, Clusters.NetworkCommissioning)], returnClusterObject=True))[0][Clusters.NetworkCommissioning] + + if network_commissioning_cluster_state.networks: + for networks in network_commissioning_cluster_state.networks: + if networks.connected: + self._logger.info( + f"Device already connected to {networks.networkID.hex()} skip network commissioning") + return + + if parameter.commissionee_info.is_wifi_device: + if network_commissioning_cluster_state.featureMap != commissioning.NetworkCommissioningFeatureMap.WIFI_NETWORK_FEATURE_MAP: + raise AssertionError("Device is expected to be a WiFi device") + return await self.network_commissioning_wifi(parameter=parameter, node_id=node_id) + elif parameter.commissionee_info.is_thread_device: + if network_commissioning_cluster_state.featureMap != commissioning.NetworkCommissioningFeatureMap.THREAD_NETWORK_FEATURE_MAP: + raise AssertionError("Device is expected to be a Thread device") + return await self.network_commissioning_thread(parameter=parameter, node_id=node_id) + + async def send_regulatory_config(self, parameter: commissioning.Parameters, node_id: int): + self._logger.info("Sending Regulatory Config") + response = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.GeneralCommissioning.Commands.SetRegulatoryConfig( + newRegulatoryConfig=Clusters.GeneralCommissioning.Enums.RegulatoryLocationType( + parameter.regulatory_config.location_type), + countryCode=parameter.regulatory_config.country_code + )) + if response.errorCode != 0: + raise commissioning.CommissionFailure(repr(response)) + + async def complete_commission(self, node_id: int): + response = await self._devCtrl.SendCommand(node_id, commissioning.ROOT_ENDPOINT_ID, Clusters.GeneralCommissioning.Commands.CommissioningComplete()) + if response.errorCode != 0: + raise commissioning.CommissionFailure(repr(response)) diff --git a/src/controller/python/chip/commissioning/pase.py b/src/controller/python/chip/commissioning/pase.py new file mode 100644 index 00000000000000..9b7e8c5077242f --- /dev/null +++ b/src/controller/python/chip/commissioning/pase.py @@ -0,0 +1,70 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import dataclasses +import ipaddress + +from chip import ChipDeviceCtrl, commissioning, discovery + + +@dataclasses.dataclass +class Session: + node_id: int + device: ChipDeviceCtrl.DeviceProxyWrapper + + +class ContextManager: + def __init__(self, devCtrl: ChipDeviceCtrl.ChipDeviceControllerBase, node_id: int, is_ble: bool): + self.devCtrl = devCtrl + self.node_id = node_id + self.is_ble = is_ble + + def __enter__(self) -> Session: + return Session( + node_id=self.node_id, + device=self.devCtrl.GetConnectedDeviceSync(self.node_id, allowPASE=True, timeoutMs=1000)) + + def __exit__(self, type, value, traceback): + self.devCtrl.CloseSession(self.node_id) + if self.is_ble: + self.devCtrl.CloseBLEConnection(self.is_ble) + + +def establish_session(devCtrl: ChipDeviceCtrl.ChipDeviceControllerBase, parameter: commissioning.PaseParameters) -> ContextManager: + if isinstance(parameter, commissioning.PaseOverBLEParameters): + devCtrl.EstablishPASESessionBLE(parameter.setup_pin, parameter.discriminator, parameter.temporary_nodeid) + elif isinstance(parameter, commissioning.PaseOverIPParameters): + device = devCtrl.DiscoverCommissionableNodes(filterType=discovery.FilterType.LONG_DISCRIMINATOR, + filter=parameter.long_discriminator, stopOnFirst=True) + if not device: + raise ValueError("No commissionable device found") + selected_address = None + for ip in device[0].addresses: + if ipaddress.ip_address(ip).is_link_local: + # TODO(erjiaqing): To connect a device using link local address requires an interface identifier, + # however, the link local address returned from DiscoverCommissionableNodes does not have an + # interface identifier. + continue + selected_address = ip + break + if selected_address is None: + raise ValueError("The node for commissioning does not contains routable ip addresses information") + devCtrl.EstablishPASESessionIP(selected_address, parameter.setup_pin, parameter.temporary_nodeid) + else: + raise TypeError("Expect PaseOverBLEParameters or PaseOverIPParameters for establishing PASE session") + return ContextManager( + devCtrl=devCtrl, node_id=parameter.temporary_nodeid, is_ble=isinstance(parameter, commissioning.PaseOverBLEParameters)) diff --git a/src/controller/python/chip/credentials/cert.cpp b/src/controller/python/chip/credentials/cert.cpp new file mode 100644 index 00000000000000..813047ca02ec2e --- /dev/null +++ b/src/controller/python/chip/credentials/cert.cpp @@ -0,0 +1,49 @@ +/* + * + * Copyright (c) 2023 Project CHIP Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "cert.h" + +#include +#include + +using namespace chip; +using namespace chip::Credentials; + +PyChipError pychip_ConvertX509CertToChipCert(const uint8_t * x509Cert, size_t x509CertLen, uint8_t * chipCert, size_t * chipCertLen) +{ + MutableByteSpan output(chipCert, *chipCertLen); + CHIP_ERROR err = CHIP_NO_ERROR; + + VerifyOrReturnError((err = ConvertX509CertToChipCert(ByteSpan(x509Cert, x509CertLen), output)) == CHIP_NO_ERROR, + ToPyChipError(err)); + *chipCertLen = output.size(); + + return ToPyChipError(err); +} + +PyChipError pychip_ConvertChipCertToX509Cert(const uint8_t * chipCert, size_t chipCertLen, uint8_t * x509Cert, size_t * x509CertLen) +{ + MutableByteSpan output(x509Cert, *x509CertLen); + CHIP_ERROR err = CHIP_NO_ERROR; + + VerifyOrReturnError((err = ConvertChipCertToX509Cert(ByteSpan(chipCert, chipCertLen), output)) == CHIP_NO_ERROR, + ToPyChipError(err)); + *x509CertLen = output.size(); + + return ToPyChipError(err); +} diff --git a/src/controller/python/chip/credentials/cert.h b/src/controller/python/chip/credentials/cert.h new file mode 100644 index 00000000000000..5d86273db01d03 --- /dev/null +++ b/src/controller/python/chip/credentials/cert.h @@ -0,0 +1,30 @@ +/* + * + * Copyright (c) 2023 Project CHIP Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include + +extern "C" { +PyChipError pychip_ConvertX509CertToChipCert(const uint8_t * x509Cert, size_t x509CertLen, uint8_t * chipCert, + size_t * chipCertLen); +PyChipError pychip_ConvertChipCertToX509Cert(const uint8_t * chipCert, size_t chipCertLen, uint8_t * x509Cert, + size_t * x509CertLen); +} diff --git a/src/controller/python/chip/credentials/cert.py b/src/controller/python/chip/credentials/cert.py new file mode 100644 index 00000000000000..786c1a423103a6 --- /dev/null +++ b/src/controller/python/chip/credentials/cert.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import ctypes + +import chip.native + + +def _handle(): + handle = chip.native.GetLibraryHandle() + if handle.pychip_ConvertX509CertToChipCert.argtypes is None: + setter = chip.native.NativeLibraryHandleMethodArguments(handle) + setter.Set("pychip_ConvertX509CertToChipCert", chip.native.PyChipError, [ctypes.POINTER( + ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.POINTER(ctypes.c_size_t)]) + setter.Set("pychip_ConvertChipCertToX509Cert", chip.native.PyChipError, [ctypes.POINTER( + ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.POINTER(ctypes.c_size_t)]) + return handle + + +def convert_x509_cert_to_chip_cert(x509Cert: bytes) -> bytes: + """Converts a x509 certificate to CHIP Certificate.""" + output_buffer = (ctypes.c_uint8 * 1024)() + output_size = ctypes.c_size_t(1024) + + _handle().pychip_ConvertX509CertToChipCert(x509Cert, len(x509Cert), output_buffer, ctypes.byref(output_size)).raise_on_error() + + return bytes(output_buffer)[:output_size.value] + + +def convert_chip_cert_to_x509_cert(chipCert: bytes) -> bytes: + """Converts a x509 certificate to CHIP Certificate.""" + output_buffer = (ctypes.c_byte * 1024)() + output_size = ctypes.c_size_t(1024) + + _handle().pychip_ConvertChipCertToX509Cert(chipCert, len(chipCert), output_buffer, ctypes.byref(output_size)).raise_on_error() + + return bytes(output_buffer)[:output_size.value] diff --git a/src/controller/python/chip/crypto/fabric.py b/src/controller/python/chip/crypto/fabric.py new file mode 100644 index 00000000000000..3662fc164306d9 --- /dev/null +++ b/src/controller/python/chip/crypto/fabric.py @@ -0,0 +1,38 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from . import p256keypair + + +def generate_compressed_fabric_id(root_public_key: bytes, fabric_id: int) -> int: + """Generates compressed fabric id from Root CA's public key and fabric id. + + Returns: + Compressed fabric id as a int + """ + if len(root_public_key) != p256keypair.P256_PUBLIC_KEY_LENGTH and root_public_key[0] != b'\x04': + raise ValueError("Root public key must be an uncompressed P256 point.") + + return int.from_bytes(HKDF( + algorithm=hashes.SHA256(), + length=8, + salt=fabric_id.to_bytes(length=8, byteorder="big", signed=False), + info=b"CompressedFabric", + ).derive(key_material=root_public_key[1:]), byteorder="big") diff --git a/src/controller/python/chip/crypto/p256keypair.cpp b/src/controller/python/chip/crypto/p256keypair.cpp new file mode 100644 index 00000000000000..eef918a5a796b1 --- /dev/null +++ b/src/controller/python/chip/crypto/p256keypair.cpp @@ -0,0 +1,112 @@ +/* + * + * Copyright (c) 2022 Project CHIP Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include + +using namespace chip; +using namespace chip::python; +using namespace chip::Crypto; + +pychip_P256Keypair::pychip_P256Keypair(void * aPyContext, pychip_P256Keypair_ECDSA_sign_msg aSignMsgFunct, + pychip_P256Keypair_ECDH_derive_secret aDeriveSecretFunct) : + mPyContext(aPyContext), + mSignMsgFunct(aSignMsgFunct), mDeriveSecretFunct(aDeriveSecretFunct) +{} + +pychip_P256Keypair::~pychip_P256Keypair() +{ + // Just override the initialize routing to avoid calling the Initialize from the platform's code. +} + +CHIP_ERROR pychip_P256Keypair::Initialize(Crypto::ECPKeyTarget key_target) +{ + // Just override the initialize routing to avoid calling the Initialize from the platform's code. + return CHIP_NO_ERROR; +} + +CHIP_ERROR pychip_P256Keypair::Serialize(Crypto::P256SerializedKeypair & output) const +{ + return CHIP_ERROR_NOT_IMPLEMENTED; +} + +CHIP_ERROR pychip_P256Keypair::Deserialize(Crypto::P256SerializedKeypair & input) +{ + return CHIP_ERROR_NOT_IMPLEMENTED; +} + +CHIP_ERROR pychip_P256Keypair::NewCertificateSigningRequest(uint8_t * csr, size_t & csr_length) const +{ + return CHIP_ERROR_NOT_IMPLEMENTED; +} + +CHIP_ERROR pychip_P256Keypair::ECDSA_sign_msg(const uint8_t * msg, size_t msg_length, + Crypto::P256ECDSASignature & out_signature) const +{ + VerifyOrReturnError(mSignMsgFunct != nullptr, CHIP_ERROR_NOT_IMPLEMENTED); + + size_t signatureLength = out_signature.Capacity(); + + VerifyOrReturnError(mSignMsgFunct(mPyContext, msg, msg_length, out_signature.Bytes(), &signatureLength), CHIP_ERROR_INTERNAL); + out_signature.SetLength(signatureLength); + + return CHIP_NO_ERROR; +} + +CHIP_ERROR pychip_P256Keypair::ECDH_derive_secret(const Crypto::P256PublicKey & remote_public_key, + Crypto::P256ECDHDerivedSecret & out_secret) const +{ + VerifyOrReturnError(mDeriveSecretFunct != nullptr, CHIP_ERROR_NOT_IMPLEMENTED); + + size_t secretLength = out_secret.Capacity(); + + VerifyOrReturnError(mDeriveSecretFunct(mPyContext, remote_public_key.ConstBytes(), out_secret.Bytes(), &secretLength), + CHIP_ERROR_INTERNAL); + out_secret.SetLength(secretLength); + + return CHIP_NO_ERROR; +} + +void pychip_P256Keypair::UpdatePubkey(const FixedByteSpan & aPublicKey) +{ + mPublicKey = aPublicKey; + mInitialized = true; +} + +chip::python::pychip_P256Keypair * pychip_NewP256Keypair(void * pyObject, pychip_P256Keypair_ECDSA_sign_msg aSignMsgFunct, + pychip_P256Keypair_ECDH_derive_secret aDeriveSecretFunct) +{ + auto res = new pychip_P256Keypair(pyObject, aSignMsgFunct, aDeriveSecretFunct); + + return res; +} + +PyChipError pychip_P256Keypair_UpdatePubkey(chip::python::pychip_P256Keypair * this_, uint8_t * aPubKey, size_t aPubKeyLen) +{ + VerifyOrReturnError(aPubKeyLen == kP256_PublicKey_Length, ToPyChipError(CHIP_ERROR_INVALID_ARGUMENT)); + this_->UpdatePubkey(FixedByteSpan(aPubKey)); + return ToPyChipError(CHIP_NO_ERROR); +} + +void pychip_DeleteP256Keypair(chip::python::pychip_P256Keypair * this_) +{ + delete this_; +} diff --git a/src/controller/python/chip/crypto/p256keypair.h b/src/controller/python/chip/crypto/p256keypair.h new file mode 100644 index 00000000000000..5aa98461477a5b --- /dev/null +++ b/src/controller/python/chip/crypto/p256keypair.h @@ -0,0 +1,124 @@ +/* + * + * Copyright (c) 2022 Project CHIP Authors + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include + +typedef bool (*pychip_P256Keypair_ECDSA_sign_msg)(void * pyObject, const uint8_t * msg, size_t msg_length, uint8_t * out_signature, + size_t * signature_length); + +typedef bool (*pychip_P256Keypair_ECDH_derive_secret)(void * pyObject, const uint8_t * remote_public_key, uint8_t * out_secret, + size_t * out_secret_length); + +namespace chip { +namespace python { + +class pychip_P256Keypair : public Crypto::P256Keypair +{ +public: + pychip_P256Keypair(void * aPyContext, pychip_P256Keypair_ECDSA_sign_msg aSignMsgFunct, + pychip_P256Keypair_ECDH_derive_secret aDeriveSecretFunct); + ~pychip_P256Keypair() override; + + CHIP_ERROR Initialize(Crypto::ECPKeyTarget key_target) override; + + /** + * @brief Serialize the keypair. + * @return Returns a CHIP_ERROR on error, CHIP_NO_ERROR otherwise + **/ + CHIP_ERROR Serialize(Crypto::P256SerializedKeypair & output) const override; + + /** + * @brief Deserialize the keypair. + * @return Returns a CHIP_ERROR on error, CHIP_NO_ERROR otherwise + **/ + CHIP_ERROR Deserialize(Crypto::P256SerializedKeypair & input) override; + + /** + * @brief Generate a new Certificate Signing Request (CSR). + * @param csr Newly generated CSR in DER format + * @param csr_length The caller provides the length of input buffer (csr). The function returns the actual length of generated + *CSR. + * @return Returns a CHIP_ERROR on error, CHIP_NO_ERROR otherwise + **/ + CHIP_ERROR NewCertificateSigningRequest(uint8_t * csr, size_t & csr_length) const override; + + /** + * @brief A function to sign a msg using ECDSA + * @param msg Message that needs to be signed + * @param msg_length Length of message + * @param out_signature Buffer that will hold the output signature. The signature consists of: 2 EC elements (r and s), + * in raw point form (see SEC1). + * @return Returns a CHIP_ERROR on error, CHIP_NO_ERROR otherwise + **/ + CHIP_ERROR ECDSA_sign_msg(const uint8_t * msg, size_t msg_length, Crypto::P256ECDSASignature & out_signature) const override; + + /** + * @brief A function to derive a shared secret using ECDH + * + * This implements the CHIP_Crypto_ECDH(PrivateKey myPrivateKey, PublicKey theirPublicKey) cryptographic primitive + * from the specification, using this class's private key from `mKeypair` as `myPrivateKey` and the remote + * public key from `remote_public_key` as `theirPublicKey`. + * + * @param remote_public_key Public key of remote peer with which we are trying to establish secure channel. remote_public_key is + * ASN.1 DER encoded as padded big-endian field elements as described in SEC 1: Elliptic Curve Cryptography + * [https://www.secg.org/sec1-v2.pdf] + * @param out_secret Buffer to write out secret into. This is a byte array representing the x coordinate of the shared secret. + * @return Returns a CHIP_ERROR on error, CHIP_NO_ERROR otherwise + **/ + CHIP_ERROR ECDH_derive_secret(const Crypto::P256PublicKey & remote_public_key, + Crypto::P256ECDHDerivedSecret & out_secret) const override; + + /** + * @brief A function to update the public key recorded in the keypair for C++ interface. + * + * @param publicKey A buffer of publicKey, should have exactly `kP256_PublicKey_Length` bytes. + * + **/ + void UpdatePubkey(const FixedByteSpan & aPublicKey); + + /** @brief Return public key for the keypair. + **/ + const Crypto::P256PublicKey & Pubkey() const override + { + // The mPublicKey is a member of Crypto::P256Keypair and is set in Initialize + return mPublicKey; + } + +private: + void * mPyContext; + + pychip_P256Keypair_ECDSA_sign_msg mSignMsgFunct; + pychip_P256Keypair_ECDH_derive_secret mDeriveSecretFunct; +}; + +} // namespace python +} // namespace chip + +extern "C" { + +chip::python::pychip_P256Keypair * pychip_NewP256Keypair(void * pyObject, pychip_P256Keypair_ECDSA_sign_msg aSignMsgFunct, + pychip_P256Keypair_ECDH_derive_secret aDeriveSecretFunct); + +PyChipError pychip_P256Keypair_UpdatePubkey(chip::python::pychip_P256Keypair * this_, uint8_t * aPubKey, size_t aPubKeyLen); +void pychip_DeleteP256Keypair(chip::python::pychip_P256Keypair * this_); +} diff --git a/src/controller/python/chip/crypto/p256keypair.py b/src/controller/python/chip/crypto/p256keypair.py new file mode 100644 index 00000000000000..3267601b3b3e0f --- /dev/null +++ b/src/controller/python/chip/crypto/p256keypair.py @@ -0,0 +1,151 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import abc +import hashlib +from ctypes import CFUNCTYPE, POINTER, c_bool, c_char, c_size_t, c_uint8, c_uint32, c_void_p, memmove, py_object, string_at + +from chip import native +from ecdsa import ECDH, NIST256p, SigningKey + +_pychip_P256Keypair_ECDSA_sign_msg_func = CFUNCTYPE( + c_bool, py_object, POINTER(c_uint8), c_size_t, POINTER(c_uint8), POINTER(c_size_t)) + +_pychip_P256Keypair_ECDH_derive_secret_func = CFUNCTYPE(c_bool, py_object, POINTER(c_uint8), POINTER(c_uint8), POINTER(c_size_t)) + +P256_PUBLIC_KEY_LENGTH = 2 * 32 + 1 + + +@ _pychip_P256Keypair_ECDSA_sign_msg_func +def _pychip_ECDSA_sign_msg(self_: 'P256Keypair', message_buf: POINTER(c_uint8), message_size: int, signature_buf: POINTER(c_uint8), signature_buf_size: POINTER(c_size_t)) -> bool: + res = self_.ECDSA_sign_msg(string_at(message_buf, message_size)[:]) + memmove(signature_buf, res, len(res)) + signature_buf_size.content = len(res) + return True + + +@ _pychip_P256Keypair_ECDH_derive_secret_func +def _pychip_ECDH_derive_secret(self_: 'P256Keypair', remote_pubkey: POINTER(c_uint8), out_secret_buf: POINTER(c_uint8), out_secret_buf_size: POINTER(c_uint32)) -> bool: + res = self_.ECDH_derive_secret(string_at(remote_pubkey, P256_PUBLIC_KEY_LENGTH)[:]) + memmove(out_secret_buf, res, len(res)) + out_secret_buf_size.content = len(res) + return True + + +class P256Keypair: + """Represented a P256Keypair, should live longer than the one using it. + + Users are expected to hold a reference to the Keypair object. + + """ + + def __init__(self): + self._native_obj = None + + def __copy__(self): + raise NotImplementedError("P256Keypair should not be copied.") + + def __deepcopy__(self, _=None): + raise NotImplementedError("P256Keypair should not be copied.") + + def _create_native_object(self) -> c_void_p: + handle = native.GetLibraryHandle() + if not handle.pychip_NewP256Keypair.argtypes: + setter = native.NativeLibraryHandleMethodArguments(handle) + setter.Set("pychip_NewP256Keypair", c_void_p, [py_object, + _pychip_P256Keypair_ECDSA_sign_msg_func, _pychip_P256Keypair_ECDH_derive_secret_func]) + setter.Set("pychip_P256Keypair_UpdatePubkey", native.PyChipError, [c_void_p, POINTER(c_char), c_size_t]) + setter.Set("pychip_DeleteP256Keypair", None, [c_void_p]) + self._native_obj = handle.pychip_NewP256Keypair( + py_object(self), _pychip_ECDSA_sign_msg, _pychip_ECDH_derive_secret) + + self.UpdatePublicKey() + return self._native_obj + + def __del__(self): + if self._native_obj is not None: + handle = native.GetLibraryHandle() + handle.pychip_DeleteP256Keypair(c_void_p(self._native_obj)) + self._native_obj = None + + @property + def native_object(self) -> c_void_p: + if self._native_obj is None: + return self._create_native_object() + return self._native_obj + + def UpdatePublicKey(self) -> None: + ''' Update the PublicKey in the underlying C++ object. + + This function should be called when the implementation + generates a new keypair. + ''' + handle = native.GetLibraryHandle() + handle.pychip_P256Keypair_UpdatePubkey(c_void_p(self.native_object), self.public_key, len(self.public_key)).raise_on_error() + + @abc.abstractproperty + def public_key(self) -> bytes: + ''' Returns the public key of the key pair + + The return value should conform with the uncompressed format of + Section 2.3.3 of the SECG SEC 1 ("Elliptic Curve Cryptography") + standard. (i.e. 0x04 || X || Y) + + For P256Keypair, the output length should be exactly 65 bytes. + ''' + raise NotImplementedError() + + @abc.abstractmethod + def ECDSA_sign_msg(self, message: bytes) -> bytes: + raise NotImplementedError() + + @abc.abstractmethod + def ECDH_derive_secret(self, remote_pubkey: bytes) -> bytes: + ''' Derive shared secret from the local private key and remote public key. + + remote_pubkey will be a public key conforms with the uncompressed + format of section 2.3.3 of the SECG SEC 1 standard. + ''' + raise NotImplementedError() + + +class TestP256Keypair(P256Keypair): + ''' The P256Keypair for testing purpose. It is not safe for any productions use + ''' + + def __init__(self, private_key: SigningKey = None): + super().__init__() + + if private_key is None: + self._key = SigningKey.generate(NIST256p) + else: + self._key = private_key + + self._pubkey = self._key.verifying_key.to_string(encoding='uncompressed') + + @property + def public_key(self) -> bytes: + return self._pubkey + + def ECDSA_sign_msg(self, message: bytes) -> bytes: + return self._key.sign_deterministic(message, hashfunc=hashlib.sha256) + + def ECDH_derive_secret(self, remote_pubkey: bytes) -> bytes: + ecdh = ECDH(curve=NIST256p) + ecdh.load_private_key(self._key) + ecdh.load_received_public_key_bytes(remote_pubkey[1:]) + return ecdh.ecdh1.generate_sharedsecret_bytes() diff --git a/src/controller/python/test/test_scripts/base.py b/src/controller/python/test/test_scripts/base.py index 9542210bfa9b15..65d774618f1f79 100644 --- a/src/controller/python/test/test_scripts/base.py +++ b/src/controller/python/test/test_scripts/base.py @@ -38,6 +38,7 @@ import chip.native from chip import ChipDeviceCtrl from chip.ChipStack import ChipStack +from chip.crypto import p256keypair from chip.utils import CommissioningBuildingBlocks logger = logging.getLogger('PythonMatterControllerTEST') @@ -189,7 +190,7 @@ def assertValueEqual(self, expected): class BaseTestHelper: - def __init__(self, nodeid: int, paaTrustStorePath: str, testCommissioner: bool = False): + def __init__(self, nodeid: int, paaTrustStorePath: str, testCommissioner: bool = False, keypair: p256keypair.P256Keypair = None): chip.native.Init() self.chipStack = ChipStack('/tmp/repl_storage.json') @@ -197,7 +198,7 @@ def __init__(self, nodeid: int, paaTrustStorePath: str, testCommissioner: bool = self.certificateAuthority = self.certificateAuthorityManager.NewCertificateAuthority() self.fabricAdmin = self.certificateAuthority.NewFabricAdmin(vendorId=0xFFF1, fabricId=1) self.devCtrl = self.fabricAdmin.NewController( - nodeid, paaTrustStorePath, testCommissioner) + nodeid, paaTrustStorePath, testCommissioner, keypair=keypair) self.controllerNodeId = nodeid self.logger = logger self.paaTrustStorePath = paaTrustStorePath diff --git a/src/controller/python/test/test_scripts/example_python_commissioning_flow.py b/src/controller/python/test/test_scripts/example_python_commissioning_flow.py new file mode 100644 index 00000000000000..b10269257f2b08 --- /dev/null +++ b/src/controller/python/test/test_scripts/example_python_commissioning_flow.py @@ -0,0 +1,81 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import os +import random + +from chip import ChipDeviceCtrl +from chip import clusters as Clusters +from chip import commissioning +from chip.commissioning import commissioning_flow_blocks, pase + + +class ExampleCustomMatterCommissioningFlow(commissioning_flow_blocks.CommissioningFlowBlocks): + def __init__(self, devCtrl: ChipDeviceCtrl.ChipDeviceControllerBase, credential_provider: commissioning.CredentialProvider, logger: logging.Logger): + super().__init__(devCtrl=devCtrl, credential_provider=credential_provider, logger=logger) + self._logger = logger + + async def commission(self, parameter: commissioning.Parameters): + # The example uses PASE, however, the blocks uses a node_id, which supports both PASE and CASE. + with pase.establish_session(devCtrl=self._devCtrl, parameter=parameter.pase_param) as device: + node_id = device.node_id + + self._logger.info("Sending ArmFailSafe to device") + await self.arm_failsafe(node_id=node_id, duration_seconds=parameter.failsafe_expiry_length_seconds) + + self._logger.info("Setting Regulatory Configuration") + await self.send_regulatory_config(parameter=parameter, node_id=node_id) + + self._logger.info("OperationalCredentials Commissioning") + case_nodeid = await self.operational_credentials_commissioning(parameter=parameter, node_id=node_id) + + if not parameter.commissionee_info.is_ethernet_device: + self._logger.info("Network Commissioning") + await self.network_commissioning(parameter=parameter, node_id=node_id) + else: + self._logger.info("Device is an ethernet device, network commissioning not required.") + + self._logger.info("Completing Commissioning") + await self.complete_commission(case_nodeid) + + self._logger.info("Commissioning Completed") + + +class ExampleCredentialProvider: + def __init__(self, devCtrl: ChipDeviceCtrl.ChipDeviceController): + self._devCtrl = devCtrl + + async def get_attestation_nonce(self) -> bytes: + return os.urandom(32) + + async def get_csr_nonce(self) -> bytes: + return os.urandom(32) + + async def get_commissionee_credentials(self, request: commissioning.GetCommissioneeCredentialsRequest) -> commissioning.GetCommissioneeCredentialsResponse: + node_id = random.randint(100000, 999999) + nocChain = self._devCtrl.IssueNOCChain(Clusters.OperationalCredentials.Commands.CSRResponse( + NOCSRElements=request.csr_elements, attestationSignature=request.attestation_signature), nodeId=node_id) + return commissioning.GetCommissioneeCredentialsResponse( + rcac=nocChain.rcacBytes, + noc=nocChain.nocBytes, + icac=nocChain.icacBytes, + ipk=nocChain.ipkBytes, + case_admin_node=self._devCtrl.nodeId, + admin_vendor_id=self._devCtrl.fabricAdmin.vendorId, + node_id=node_id, + fabric_id=self._devCtrl.fabricId) diff --git a/src/controller/python/test/test_scripts/python_commissioning_flow_test.py b/src/controller/python/test/test_scripts/python_commissioning_flow_test.py new file mode 100755 index 00000000000000..2317a5571e7257 --- /dev/null +++ b/src/controller/python/test/test_scripts/python_commissioning_flow_test.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +# +# Copyright (c) 2021 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import asyncio +# Commissioning test. +import os +import random +import sys +from optparse import OptionParser + +import example_python_commissioning_flow +from base import BaseTestHelper, TestFail, TestTimeout, logger +from chip import ChipDeviceCtrl +from chip import clusters as Clusters +from chip import commissioning +from chip.crypto import p256keypair + +# The thread network dataset tlv for testing, splited into T-L-V. + +TEST_THREAD_NETWORK_DATASET_TLV = "0e080000000000010000" + \ + "000300000c" + \ + "35060004001fffe0" + \ + "0208fedcba9876543210" + \ + "0708fd00000000001234" + \ + "0510ffeeddccbbaa99887766554433221100" + \ + "030e54657374696e674e6574776f726b" + \ + "0102d252" + \ + "041081cb3b2efa781cc778397497ff520fa50c0302a0ff" +# Network id, for the thread network, current a const value, will be changed to XPANID of the thread network. +TEST_THREAD_NETWORK_ID = "fedcba9876543210" +TEST_DISCRIMINATOR = 3840 + +ENDPOINT_ID = 0 +LIGHTING_ENDPOINT_ID = 1 +GROUP_ID = 0 + + +def main(): + optParser = OptionParser() + optParser.add_option( + "-t", + "--timeout", + action="store", + dest="testTimeout", + default=75, + type='int', + help="The program will return with timeout after specified seconds.", + metavar="", + ) + optParser.add_option( + "--bad-cert-issuer", + action="store_true", + dest="badCertIssuer", + default=False, + help="Simulate a bad certificate issuer, the commissioning should fail when sending OpCreds.", + ) + optParser.add_option( + "-d", + "--discriminator", + action="store", + dest="discriminator", + default='', + type='str', + help="The long discriminator of the device", + metavar="", + ) + optParser.add_option( + "--setup-payload", + action="store", + dest="setupPayload", + default='', + type='str', + help="Setup Payload (manual pairing code or QR code content)", + metavar="" + ) + optParser.add_option( + "--nodeid", + action="store", + dest="nodeid", + default=1, + type=int, + help="The Node ID issued to the device", + metavar="" + ) + optParser.add_option( + '--paa-trust-store-path', + dest="paaPath", + default='', + type='str', + help="Path that contains valid and trusted PAA Root Certificates." + ) + + (options, remainingArgs) = optParser.parse_args(sys.argv[1:]) + + timeoutTicker = TestTimeout(options.testTimeout) + timeoutTicker.start() + + test = BaseTestHelper( + nodeid=112233, paaTrustStorePath=options.paaPath, testCommissioner=True, keypair=p256keypair.TestP256Keypair()) + + class BadCredentialProvider: + def __init__(self, devCtrl: ChipDeviceCtrl.ChipDeviceController): + self._devCtrl = devCtrl + + async def get_attestation_nonce(self) -> bytes: + return os.urandom(32) + + async def get_csr_nonce(self) -> bytes: + return os.urandom(32) + + async def get_commissionee_credentials(self, request: commissioning.GetCommissioneeCredentialsRequest) -> commissioning.GetCommissioneeCredentialsResponse: + node_id = random.randint(100000, 999999) + nocChain = self._devCtrl.IssueNOCChain(Clusters.OperationalCredentials.Commands.CSRResponse( + NOCSRElements=request.csr_elements, attestationSignature=request.attestation_signature), nodeId=node_id) + return commissioning.GetCommissioneeCredentialsResponse( + rcac=nocChain.rcacBytes[1:], + noc=nocChain.nocBytes[1:], + icac=nocChain.icacBytes[1:], + ipk=nocChain.ipkBytes[1:], + case_admin_node=self._devCtrl.nodeId, + admin_vendor_id=self._devCtrl.fabricAdmin.vendorId, + node_id=node_id, + fabric_id=self._devCtrl.fabricId) + + flow = example_python_commissioning_flow.ExampleCustomMatterCommissioningFlow( + devCtrl=test.devCtrl, + credential_provider=BadCredentialProvider( + test.devCtrl) if options.badCertIssuer else example_python_commissioning_flow.ExampleCredentialProvider(test.devCtrl), + logger=logger) + + try: + asyncio.run(flow.commission(commissioning.Parameters( + pase_param=commissioning.PaseOverIPParameters( + long_discriminator=options.discriminator, + setup_pin=20202021, temporary_nodeid=options.nodeid + ), + regulatory_config=commissioning.RegulatoryConfig( + location_type=commissioning.RegulatoryLocationType.INDOOR_OUTDOOR, country_code='US'), + fabric_label="TestFabric", + commissionee_info=commissioning.CommissioneeInfo( + endpoints={}, + is_thread_device=True, + is_ethernet_device=False, + is_wifi_device=False, + ), + wifi_credentials=None, + thread_credentials=bytes.fromhex(TEST_THREAD_NETWORK_DATASET_TLV)))) + if options.badCertIssuer: + raise AssertionError("The commission is expected to fail. (BadCredentialProvider used)") + except Exception as ex: + if options.badCertIssuer: + logger.exception("Got exception and the test is expected to fail (BadCredentialProvider used)") + else: + raise ex + + timeoutTicker.stop() + + logger.info("Test finished") + + # TODO: Python device controller cannot be shutdown clean sometimes and will block on AsyncDNSResolverSockets shutdown. + # Call os._exit(0) to force close it. + os._exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception as ex: + logger.exception(ex) + TestFail("Exception occurred when running tests.") diff --git a/src/test_driver/linux-cirque/PythonCommissioningTest.py b/src/test_driver/linux-cirque/PythonCommissioningTest.py new file mode 100755 index 00000000000000..052474a48c8f47 --- /dev/null +++ b/src/test_driver/linux-cirque/PythonCommissioningTest.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021 Project CHIP Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import logging +import os +import pprint +import sys +import time + +from helper.CHIPTestBase import CHIPVirtualHome + +logger = logging.getLogger('MobileDeviceTest') +logger.setLevel(logging.INFO) + +sh = logging.StreamHandler() +sh.setFormatter( + logging.Formatter( + '%(asctime)s [%(name)s] %(levelname)s %(message)s')) +logger.addHandler(sh) + +CHIP_PORT = 5540 + +CIRQUE_URL = "http://localhost:5000" +CHIP_REPO = os.path.join(os.path.abspath( + os.path.dirname(__file__)), "..", "..", "..") +TEST_EXTPANID = "fedcba9876543210" +TEST_DISCRIMINATOR = 3840 +TEST_DISCRIMINATOR2 = 3584 +MATTER_DEVELOPMENT_PAA_ROOT_CERTS = "credentials/development/paa-root-certs" + +DEVICE_CONFIG = { + 'device0': { + 'type': 'MobileDevice', + 'base_image': 'connectedhomeip/chip-cirque-device-base', + 'capability': ['TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 100}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + }, + 'device1': { + 'type': 'CHIPEndDevice', + 'base_image': 'connectedhomeip/chip-cirque-device-base', + 'capability': ['Thread', 'TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 100}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + }, + 'device2': { + 'type': 'CHIPEndDevice', + 'base_image': 'connectedhomeip/chip-cirque-device-base', + 'capability': ['Thread', 'TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 100}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + } +} + + +class TestCommissioner(CHIPVirtualHome): + def __init__(self, device_config): + super().__init__(CIRQUE_URL, device_config) + self.logger = logger + + def setup(self): + self.initialize_home() + + def test_routine(self): + self.run_controller_test() + + def run_controller_test(self): + servers = [{ + "ip": device['description']['ipv6_addr'], + "id": device['id'] + } for device in self.non_ap_devices + if device['type'] == 'CHIPEndDevice'] + req_ids = [device['id'] for device in self.non_ap_devices + if device['type'] == 'MobileDevice'] + + servers[0]['discriminator'] = TEST_DISCRIMINATOR + servers[0]['nodeid'] = 1 + servers[1]['discriminator'] = TEST_DISCRIMINATOR2 + servers[1]['nodeid'] = 2 + + for server in servers: + self.execute_device_cmd(server['id'], "CHIPCirqueDaemon.py -- run gdb -return-child-result -q -ex \"set pagination off\" -ex run -ex \"bt 25\" --args {} --thread --discriminator {}".format( + os.path.join(CHIP_REPO, "out/debug/standalone/chip-all-clusters-app"), server['discriminator'])) + + self.reset_thread_devices([server['id'] for server in servers]) + + req_device_id = req_ids[0] + + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_clusters-0.0-py3-none-any.whl"))) + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_core-0.0-cp37-abi3-linux_x86_64.whl"))) + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_repl-0.0-py3-none-any.whl"))) + + command = "gdb -return-child-result -q -ex run -ex bt --args python3 {} -t 150 -d {} --paa-trust-store-path {} --nodeid {}".format( + os.path.join( + CHIP_REPO, "src/controller/python/test/test_scripts/python_commissioning_flow_test.py"), + TEST_DISCRIMINATOR, + os.path.join(CHIP_REPO, MATTER_DEVELOPMENT_PAA_ROOT_CERTS), + servers[0]['nodeid']) + ret = self.execute_device_cmd(req_device_id, command) + + self.assertEqual(ret['return_code'], '0', + "Test failed: non-zero return code") + + command = "gdb -return-child-result -q -ex run -ex bt --args python3 {} -t 150 -d {} --paa-trust-store-path {} --nodeid {} --bad-cert-issuer".format( + os.path.join( + CHIP_REPO, "src/controller/python/test/test_scripts/python_commissioning_flow_test.py"), + TEST_DISCRIMINATOR2, + os.path.join(CHIP_REPO, MATTER_DEVELOPMENT_PAA_ROOT_CERTS), + servers[1]['nodeid']) + ret = self.execute_device_cmd(req_device_id, command) + + self.assertEqual(ret['return_code'], '0', + "Test failed: non-zero return code") + + +if __name__ == "__main__": + sys.exit(TestCommissioner(DEVICE_CONFIG).run_test())