| /* |
| * Copyright 2019, The Android Open Source Project |
| * |
| * 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. |
| */ |
| |
| #define LOG_TAG "IdentityCredential" |
| |
| #include "IdentityCredential.h" |
| #include "IdentityCredentialStore.h" |
| |
| #include <android/hardware/identity/support/IdentityCredentialSupport.h> |
| |
| #include <string.h> |
| |
| #include <android-base/logging.h> |
| #include <android-base/stringprintf.h> |
| |
| #include <cppbor.h> |
| #include <cppbor_parse.h> |
| |
| #include "FakeSecureHardwareProxy.h" |
| #include "WritableIdentityCredential.h" |
| |
| namespace aidl::android::hardware::identity { |
| |
| using ::aidl::android::hardware::keymaster::Timestamp; |
| using ::android::base::StringPrintf; |
| using ::std::optional; |
| |
| using namespace ::android::hardware::identity; |
| |
| int IdentityCredential::initialize() { |
| if (credentialData_.size() == 0) { |
| LOG(ERROR) << "CredentialData is empty"; |
| return IIdentityCredentialStore::STATUS_INVALID_DATA; |
| } |
| auto [item, _, message] = cppbor::parse(credentialData_); |
| if (item == nullptr) { |
| LOG(ERROR) << "CredentialData is not valid CBOR: " << message; |
| return IIdentityCredentialStore::STATUS_INVALID_DATA; |
| } |
| |
| const cppbor::Array* arrayItem = item->asArray(); |
| if (arrayItem == nullptr || arrayItem->size() != 3) { |
| LOG(ERROR) << "CredentialData is not an array with three elements"; |
| return IIdentityCredentialStore::STATUS_INVALID_DATA; |
| } |
| |
| const cppbor::Tstr* docTypeItem = (*arrayItem)[0]->asTstr(); |
| const cppbor::Bool* testCredentialItem = |
| ((*arrayItem)[1]->asSimple() != nullptr ? ((*arrayItem)[1]->asSimple()->asBool()) |
| : nullptr); |
| const cppbor::Bstr* encryptedCredentialKeysItem = (*arrayItem)[2]->asBstr(); |
| if (docTypeItem == nullptr || testCredentialItem == nullptr || |
| encryptedCredentialKeysItem == nullptr) { |
| LOG(ERROR) << "CredentialData unexpected item types"; |
| return IIdentityCredentialStore::STATUS_INVALID_DATA; |
| } |
| |
| docType_ = docTypeItem->value(); |
| testCredential_ = testCredentialItem->value(); |
| |
| encryptedCredentialKeys_ = encryptedCredentialKeysItem->value(); |
| |
| // If in a session, delay the initialization of the proxy. |
| // |
| if (!session_) { |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| LOG(ERROR) << "Error initializing hw proxy"; |
| return IIdentityCredentialStore::STATUS_FAILED; |
| } |
| } |
| |
| return IIdentityCredentialStore::STATUS_OK; |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::ensureHwProxy() { |
| if (hwProxy_) { |
| return ndk::ScopedAStatus::ok(); |
| } |
| hwProxy_ = hwProxyFactory_->createPresentationProxy(); |
| if (!hwProxy_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error creating hw proxy")); |
| } |
| uint64_t sessionId = session_ ? session_->getSessionId() : EIC_PRESENTATION_ID_UNSET; |
| if (!hwProxy_->initialize(sessionId, testCredential_, docType_, encryptedCredentialKeys_)) { |
| hwProxy_.clear(); |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error initializing hw proxy")); |
| } |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::deleteCredential( |
| vector<uint8_t>* outProofOfDeletionSignature) { |
| return deleteCredentialCommon({}, false, outProofOfDeletionSignature); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::deleteCredentialWithChallenge( |
| const vector<uint8_t>& challenge, vector<uint8_t>* outProofOfDeletionSignature) { |
| return deleteCredentialCommon(challenge, true, outProofOfDeletionSignature); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::deleteCredentialCommon( |
| const vector<uint8_t>& challenge, bool includeChallenge, |
| vector<uint8_t>* outProofOfDeletionSignature) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| if (challenge.size() > 32) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, "Challenge too big")); |
| } |
| |
| cppbor::Array array = {"ProofOfDeletion", docType_, testCredential_}; |
| if (includeChallenge) { |
| array = {"ProofOfDeletion", docType_, challenge, testCredential_}; |
| } |
| |
| vector<uint8_t> proofOfDeletionCbor = array.encode(); |
| vector<uint8_t> podDigest = support::sha256(proofOfDeletionCbor); |
| |
| optional<vector<uint8_t>> signatureOfToBeSigned = hwProxy_->deleteCredential( |
| docType_, challenge, includeChallenge, proofOfDeletionCbor.size()); |
| if (!signatureOfToBeSigned) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error signing ProofOfDeletion")); |
| } |
| |
| optional<vector<uint8_t>> signature = |
| support::coseSignEcDsaWithSignature(signatureOfToBeSigned.value(), |
| proofOfDeletionCbor, // data |
| {}); // certificateChain |
| if (!signature) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error signing data")); |
| } |
| |
| *outProofOfDeletionSignature = signature.value(); |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::proveOwnership( |
| const vector<uint8_t>& challenge, vector<uint8_t>* outProofOfOwnershipSignature) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| if (challenge.size() > 32) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, "Challenge too big")); |
| } |
| |
| cppbor::Array array; |
| array = {"ProofOfOwnership", docType_, challenge, testCredential_}; |
| vector<uint8_t> proofOfOwnershipCbor = array.encode(); |
| vector<uint8_t> podDigest = support::sha256(proofOfOwnershipCbor); |
| |
| optional<vector<uint8_t>> signatureOfToBeSigned = hwProxy_->proveOwnership( |
| docType_, testCredential_, challenge, proofOfOwnershipCbor.size()); |
| if (!signatureOfToBeSigned) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error signing ProofOfOwnership")); |
| } |
| |
| optional<vector<uint8_t>> signature = |
| support::coseSignEcDsaWithSignature(signatureOfToBeSigned.value(), |
| proofOfOwnershipCbor, // data |
| {}); // certificateChain |
| if (!signature) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error signing data")); |
| } |
| |
| *outProofOfOwnershipSignature = signature.value(); |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::createEphemeralKeyPair(vector<uint8_t>* outKeyPair) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| optional<vector<uint8_t>> ephemeralPriv = hwProxy_->createEphemeralKeyPair(); |
| if (!ephemeralPriv) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error creating ephemeral key")); |
| } |
| optional<vector<uint8_t>> keyPair = support::ecPrivateKeyToKeyPair(ephemeralPriv.value()); |
| if (!keyPair) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error creating ephemeral key-pair")); |
| } |
| |
| // Stash public key of this key-pair for later check in startRetrieval(). |
| optional<vector<uint8_t>> publicKey = support::ecKeyPairGetPublicKey(keyPair.value()); |
| if (!publicKey) { |
| LOG(ERROR) << "Error getting public part of ephemeral key pair"; |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Error getting public part of ephemeral key pair")); |
| } |
| ephemeralPublicKey_ = publicKey.value(); |
| |
| *outKeyPair = keyPair.value(); |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::setReaderEphemeralPublicKey( |
| const vector<uint8_t>& publicKey) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| readerPublicKey_ = publicKey; |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::createAuthChallenge(int64_t* outChallenge) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| optional<uint64_t> challenge = hwProxy_->createAuthChallenge(); |
| if (!challenge) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error generating challenge")); |
| } |
| *outChallenge = challenge.value(); |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::setRequestedNamespaces( |
| const vector<RequestNamespace>& requestNamespaces) { |
| requestNamespaces_ = requestNamespaces; |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::setVerificationToken( |
| const VerificationToken& verificationToken) { |
| verificationToken_ = verificationToken; |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::startRetrieval( |
| const vector<SecureAccessControlProfile>& accessControlProfiles, |
| const HardwareAuthToken& authToken, const vector<uint8_t>& itemsRequest, |
| const vector<uint8_t>& signingKeyBlob, const vector<uint8_t>& sessionTranscript, |
| const vector<uint8_t>& readerSignature, const vector<int32_t>& requestCounts) { |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| |
| // If in a session, ensure the passed-in session transcript matches the |
| // session transcript from the session. |
| if (session_) { |
| if (sessionTranscript != session_->getSessionTranscript()) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_SESSION_TRANSCRIPT_MISMATCH, |
| "In a session and passed-in SessionTranscript doesn't match the one " |
| "from the session")); |
| } |
| } |
| |
| if (numStartRetrievalCalls_ > 0) { |
| if (sessionTranscript_ != sessionTranscript) { |
| LOG(ERROR) << "Session Transcript changed"; |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_SESSION_TRANSCRIPT_MISMATCH, |
| "Passed-in SessionTranscript doesn't match previously used SessionTranscript")); |
| } |
| } |
| sessionTranscript_ = sessionTranscript; |
| |
| // This resets various state in the TA... |
| if (!hwProxy_->startRetrieveEntries()) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error starting retrieving entries")); |
| } |
| |
| optional<vector<uint8_t>> signatureOfToBeSigned; |
| if (readerSignature.size() > 0) { |
| signatureOfToBeSigned = support::coseSignGetSignature(readerSignature); |
| if (!signatureOfToBeSigned) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| "Error extracting signatureOfToBeSigned from COSE_Sign1")); |
| } |
| } |
| |
| // Feed the auth token to secure hardware only if they're valid. |
| if (authToken.timestamp.milliSeconds != 0) { |
| if (!hwProxy_->setAuthToken( |
| authToken.challenge, authToken.userId, authToken.authenticatorId, |
| int(authToken.authenticatorType), authToken.timestamp.milliSeconds, |
| authToken.mac, verificationToken_.challenge, |
| verificationToken_.timestamp.milliSeconds, |
| int(verificationToken_.securityLevel), verificationToken_.mac)) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, "Invalid Auth Token")); |
| } |
| } |
| |
| // We'll be feeding ACPs interleaved with certificates from the reader |
| // certificate chain... |
| vector<SecureAccessControlProfile> remainingAcps = accessControlProfiles; |
| |
| // ... and we'll use those ACPs to build up a 32-bit mask indicating which |
| // of the possible 32 ACPs grants access. |
| uint32_t accessControlProfileMask = 0; |
| |
| // If there is a signature, validate that it was made with the top-most key in the |
| // certificate chain embedded in the COSE_Sign1 structure. |
| optional<vector<uint8_t>> readerCertificateChain; |
| if (readerSignature.size() > 0) { |
| readerCertificateChain = support::coseSignGetX5Chain(readerSignature); |
| if (!readerCertificateChain) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| "Unable to get reader certificate chain from COSE_Sign1")); |
| } |
| |
| // First, feed all the reader certificates to the secure hardware. We start |
| // at the end.. |
| optional<vector<vector<uint8_t>>> splitCerts = |
| support::certificateChainSplit(readerCertificateChain.value()); |
| if (!splitCerts || splitCerts.value().size() == 0) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| "Error splitting certificate chain from COSE_Sign1")); |
| } |
| for (ssize_t n = splitCerts.value().size() - 1; n >= 0; --n) { |
| const vector<uint8_t>& x509Cert = splitCerts.value()[n]; |
| if (!hwProxy_->pushReaderCert(x509Cert)) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| StringPrintf("Error validating reader certificate %zd", n).c_str())); |
| } |
| |
| // If we have ACPs for that particular certificate, send them to the |
| // TA right now... |
| // |
| // Remember in this case certificate equality is done by comparing public keys, |
| // not bitwise comparison of the certificates. |
| // |
| optional<vector<uint8_t>> x509CertPubKey = |
| support::certificateChainGetTopMostKey(x509Cert); |
| if (!x509CertPubKey) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| StringPrintf("Error getting public key from reader certificate %zd", n) |
| .c_str())); |
| } |
| vector<SecureAccessControlProfile>::iterator it = remainingAcps.begin(); |
| while (it != remainingAcps.end()) { |
| const SecureAccessControlProfile& profile = *it; |
| if (profile.readerCertificate.encodedCertificate.size() == 0) { |
| ++it; |
| continue; |
| } |
| optional<vector<uint8_t>> profilePubKey = support::certificateChainGetTopMostKey( |
| profile.readerCertificate.encodedCertificate); |
| if (!profilePubKey) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Error getting public key from profile")); |
| } |
| if (profilePubKey.value() == x509CertPubKey.value()) { |
| optional<bool> res = hwProxy_->validateAccessControlProfile( |
| profile.id, profile.readerCertificate.encodedCertificate, |
| profile.userAuthenticationRequired, profile.timeoutMillis, |
| profile.secureUserId, profile.mac); |
| if (!res) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Error validating access control profile")); |
| } |
| if (res.value()) { |
| accessControlProfileMask |= (1 << profile.id); |
| } |
| it = remainingAcps.erase(it); |
| } else { |
| ++it; |
| } |
| } |
| } |
| |
| // ... then pass the request message and have the TA check it's signed by the |
| // key in last certificate we pushed. |
| if (sessionTranscript.size() > 0 && itemsRequest.size() > 0 && readerSignature.size() > 0) { |
| optional<vector<uint8_t>> tbsSignature = support::coseSignGetSignature(readerSignature); |
| if (!tbsSignature) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| "Error extracting toBeSigned from COSE_Sign1")); |
| } |
| optional<int> coseSignAlg = support::coseSignGetAlg(readerSignature); |
| if (!coseSignAlg) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| "Error extracting signature algorithm from COSE_Sign1")); |
| } |
| if (!hwProxy_->validateRequestMessage(sessionTranscript, itemsRequest, |
| coseSignAlg.value(), tbsSignature.value())) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_SIGNATURE_CHECK_FAILED, |
| "readerMessage is not signed by top-level certificate")); |
| } |
| } |
| } |
| |
| // Feed remaining access control profiles... |
| for (const SecureAccessControlProfile& profile : remainingAcps) { |
| optional<bool> res = hwProxy_->validateAccessControlProfile( |
| profile.id, profile.readerCertificate.encodedCertificate, |
| profile.userAuthenticationRequired, profile.timeoutMillis, profile.secureUserId, |
| profile.mac); |
| if (!res) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Error validating access control profile")); |
| } |
| if (res.value()) { |
| accessControlProfileMask |= (1 << profile.id); |
| } |
| } |
| |
| if (session_) { |
| // If presenting in a session, the TA has already done the check for (X, Y) as done |
| // below, see eicSessionSetSessionTranscript(). |
| } else { |
| // If mdoc session encryption is in use, check that the |
| // public part of the ephemeral key we previously created, is |
| // present in the DeviceEngagement part of SessionTranscript |
| // as a COSE_Key, in uncompressed form. |
| // |
| // We do this by just searching for the X and Y coordinates. |
| if (sessionTranscript.size() > 0 && ephemeralPublicKey_.size() > 0) { |
| auto [getXYSuccess, ePubX, ePubY] = support::ecPublicKeyGetXandY(ephemeralPublicKey_); |
| if (!getXYSuccess) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_EPHEMERAL_PUBLIC_KEY_NOT_FOUND, |
| "Error extracting X and Y from ePub")); |
| } |
| if (sessionTranscript.size() > 0 && |
| !(memmem(sessionTranscript.data(), sessionTranscript.size(), ePubX.data(), |
| ePubX.size()) != nullptr && |
| memmem(sessionTranscript.data(), sessionTranscript.size(), ePubY.data(), |
| ePubY.size()) != nullptr)) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_EPHEMERAL_PUBLIC_KEY_NOT_FOUND, |
| "Did not find ephemeral public key's X and Y coordinates in " |
| "SessionTranscript (make sure leading zeroes are not used)")); |
| } |
| } |
| } |
| |
| // itemsRequest: If non-empty, contains request data that may be signed by the |
| // reader. The content can be defined in the way appropriate for the |
| // credential, but there are three requirements that must be met to work with |
| // this HAL: |
| if (itemsRequest.size() > 0) { |
| // 1. The content must be a CBOR-encoded structure. |
| auto [item, _, message] = cppbor::parse(itemsRequest); |
| if (item == nullptr) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_ITEMS_REQUEST_MESSAGE, |
| "Error decoding CBOR in itemsRequest")); |
| } |
| |
| // 2. The CBOR structure must be a map. |
| const cppbor::Map* map = item->asMap(); |
| if (map == nullptr) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_ITEMS_REQUEST_MESSAGE, |
| "itemsRequest is not a CBOR map")); |
| } |
| |
| // 3. The map must contain a key "nameSpaces" whose value contains a map, as described in |
| // the example below. |
| // |
| // NameSpaces = { |
| // + NameSpace => DataElements ; Requested data elements for each NameSpace |
| // } |
| // |
| // NameSpace = tstr |
| // |
| // DataElements = { |
| // + DataElement => IntentToRetain |
| // } |
| // |
| // DataElement = tstr |
| // IntentToRetain = bool |
| // |
| // Here's an example of an |itemsRequest| CBOR value satisfying above requirements 1. |
| // through 3.: |
| // |
| // { |
| // 'docType' : 'org.iso.18013-5.2019', |
| // 'nameSpaces' : { |
| // 'org.iso.18013-5.2019' : { |
| // 'Last name' : false, |
| // 'Birth date' : false, |
| // 'First name' : false, |
| // 'Home address' : true |
| // }, |
| // 'org.aamva.iso.18013-5.2019' : { |
| // 'Real Id' : false |
| // } |
| // } |
| // } |
| // |
| const cppbor::Map* nsMap = nullptr; |
| for (size_t n = 0; n < map->size(); n++) { |
| const auto& [keyItem, valueItem] = (*map)[n]; |
| if (keyItem->type() == cppbor::TSTR && keyItem->asTstr()->value() == "nameSpaces" && |
| valueItem->type() == cppbor::MAP) { |
| nsMap = valueItem->asMap(); |
| break; |
| } |
| } |
| if (nsMap == nullptr) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_ITEMS_REQUEST_MESSAGE, |
| "No nameSpaces map in top-most map")); |
| } |
| |
| for (size_t n = 0; n < nsMap->size(); n++) { |
| auto& [nsKeyItem, nsValueItem] = (*nsMap)[n]; |
| const cppbor::Tstr* nsKey = nsKeyItem->asTstr(); |
| const cppbor::Map* nsInnerMap = nsValueItem->asMap(); |
| if (nsKey == nullptr || nsInnerMap == nullptr) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_ITEMS_REQUEST_MESSAGE, |
| "Type mismatch in nameSpaces map")); |
| } |
| string requestedNamespace = nsKey->value(); |
| set<string> requestedKeys; |
| for (size_t m = 0; m < nsInnerMap->size(); m++) { |
| const auto& [innerMapKeyItem, innerMapValueItem] = (*nsInnerMap)[m]; |
| const cppbor::Tstr* nameItem = innerMapKeyItem->asTstr(); |
| const cppbor::Simple* simple = innerMapValueItem->asSimple(); |
| const cppbor::Bool* intentToRetainItem = |
| (simple != nullptr) ? simple->asBool() : nullptr; |
| if (nameItem == nullptr || intentToRetainItem == nullptr) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_ITEMS_REQUEST_MESSAGE, |
| "Type mismatch in value in nameSpaces map")); |
| } |
| requestedKeys.insert(nameItem->value()); |
| } |
| requestedNameSpacesAndNames_[requestedNamespace] = requestedKeys; |
| } |
| } |
| |
| deviceNameSpacesMap_ = cppbor::Map(); |
| currentNameSpaceDeviceNameSpacesMap_ = cppbor::Map(); |
| |
| requestCountsRemaining_ = requestCounts; |
| currentNameSpace_ = ""; |
| |
| itemsRequest_ = itemsRequest; |
| signingKeyBlob_ = signingKeyBlob; |
| |
| // calculate the size of DeviceNameSpaces. We need to know it ahead of time. |
| calcDeviceNameSpacesSize(accessControlProfileMask); |
| |
| // Count the number of non-empty namespaces |
| size_t numNamespacesWithValues = 0; |
| for (size_t n = 0; n < expectedNumEntriesPerNamespace_.size(); n++) { |
| if (expectedNumEntriesPerNamespace_[n] > 0) { |
| numNamespacesWithValues += 1; |
| } |
| } |
| |
| // Finally, pass info so the HMAC key can be derived and the TA can start |
| // creating the DeviceNameSpaces CBOR... |
| if (!session_) { |
| if (sessionTranscript_.size() > 0 && signingKeyBlob.size() > 0) { |
| vector<uint8_t> eReaderKeyP256; |
| if (readerPublicKey_.size() > 0) { |
| // If set, we expect the reader ephemeral public key to be same size and curve |
| // as the ephemeral key we generated (e.g. P-256 key), otherwise ECDH won't |
| // work. So its length should be 65 bytes and it should be starting with 0x04. |
| if (readerPublicKey_.size() != 65 || readerPublicKey_[0] != 0x04) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Reader public key is not in expected format")); |
| } |
| eReaderKeyP256 = |
| vector<uint8_t>(readerPublicKey_.begin() + 1, readerPublicKey_.end()); |
| } |
| if (!hwProxy_->prepareDeviceAuthentication( |
| sessionTranscript_, eReaderKeyP256, signingKeyBlob, docType_, |
| numNamespacesWithValues, expectedDeviceNameSpacesSize_)) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Error starting retrieving entries")); |
| } |
| } |
| } else { |
| if (session_->getSessionTranscript().size() > 0 && signingKeyBlob.size() > 0) { |
| // Don't actually pass the reader ephemeral public key in, the TA will get |
| // it from the session object. |
| // |
| if (!hwProxy_->prepareDeviceAuthentication(sessionTranscript_, {}, signingKeyBlob, |
| docType_, numNamespacesWithValues, |
| expectedDeviceNameSpacesSize_)) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Error starting retrieving entries")); |
| } |
| } |
| } |
| |
| numStartRetrievalCalls_ += 1; |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| size_t cborNumBytesForLength(size_t length) { |
| if (length < 24) { |
| return 0; |
| } else if (length <= 0xff) { |
| return 1; |
| } else if (length <= 0xffff) { |
| return 2; |
| } else if (length <= 0xffffffff) { |
| return 4; |
| } |
| return 8; |
| } |
| |
| size_t cborNumBytesForTstr(const string& value) { |
| return 1 + cborNumBytesForLength(value.size()) + value.size(); |
| } |
| |
| void IdentityCredential::calcDeviceNameSpacesSize(uint32_t accessControlProfileMask) { |
| /* |
| * This is how DeviceNameSpaces is defined: |
| * |
| * DeviceNameSpaces = { |
| * * NameSpace => DeviceSignedItems |
| * } |
| * DeviceSignedItems = { |
| * + DataItemName => DataItemValue |
| * } |
| * |
| * Namespace = tstr |
| * DataItemName = tstr |
| * DataItemValue = any |
| * |
| * This function will calculate its length using knowledge of how CBOR is |
| * encoded. |
| */ |
| size_t ret = 0; |
| vector<unsigned int> numEntriesPerNamespace; |
| for (const RequestNamespace& rns : requestNamespaces_) { |
| vector<RequestDataItem> itemsToInclude; |
| |
| for (const RequestDataItem& rdi : rns.items) { |
| // If we have a CBOR request message, skip if item isn't in it |
| if (itemsRequest_.size() > 0) { |
| const auto& it = requestedNameSpacesAndNames_.find(rns.namespaceName); |
| if (it == requestedNameSpacesAndNames_.end()) { |
| continue; |
| } |
| const set<string>& dataItemNames = it->second; |
| if (dataItemNames.find(rdi.name) == dataItemNames.end()) { |
| continue; |
| } |
| } |
| |
| // Access is granted if at least one of the profiles grants access. |
| // |
| // If an item is configured without any profiles, access is denied. |
| // |
| bool authorized = false; |
| for (auto id : rdi.accessControlProfileIds) { |
| if (accessControlProfileMask & (1 << id)) { |
| authorized = true; |
| break; |
| } |
| } |
| if (!authorized) { |
| continue; |
| } |
| |
| itemsToInclude.push_back(rdi); |
| } |
| |
| numEntriesPerNamespace.push_back(itemsToInclude.size()); |
| |
| // If no entries are to be in the namespace, we don't include it in |
| // the CBOR... |
| if (itemsToInclude.size() == 0) { |
| continue; |
| } |
| |
| // Key: NameSpace |
| ret += cborNumBytesForTstr(rns.namespaceName); |
| |
| // Value: Open the DeviceSignedItems map |
| ret += 1 + cborNumBytesForLength(itemsToInclude.size()); |
| |
| for (const RequestDataItem& item : itemsToInclude) { |
| // Key: DataItemName |
| ret += cborNumBytesForTstr(item.name); |
| |
| // Value: DataItemValue - entryData.size is the length of serialized CBOR so we use |
| // that. |
| ret += item.size; |
| } |
| } |
| |
| // Now that we know the number of namespaces with values, we know how many |
| // bytes the DeviceNamespaces map in the beginning is going to take up. |
| ret += 1 + cborNumBytesForLength(numEntriesPerNamespace.size()); |
| |
| expectedDeviceNameSpacesSize_ = ret; |
| expectedNumEntriesPerNamespace_ = numEntriesPerNamespace; |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::startRetrieveEntryValue( |
| const string& nameSpace, const string& name, int32_t entrySize, |
| const vector<int32_t>& accessControlProfileIds) { |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| |
| if (name.empty()) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, "Name cannot be empty")); |
| } |
| if (nameSpace.empty()) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, "Name space cannot be empty")); |
| } |
| |
| if (requestCountsRemaining_.size() == 0) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "No more name spaces left to go through")); |
| } |
| |
| bool newNamespace; |
| if (currentNameSpace_ == "") { |
| // First call. |
| currentNameSpace_ = nameSpace; |
| newNamespace = true; |
| } |
| |
| if (nameSpace == currentNameSpace_) { |
| // Same namespace. |
| if (requestCountsRemaining_[0] == 0) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "No more entries to be retrieved in current name space")); |
| } |
| requestCountsRemaining_[0] -= 1; |
| } else { |
| // New namespace. |
| if (requestCountsRemaining_[0] != 0) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Moved to new name space but one or more entries need to be retrieved " |
| "in current name space")); |
| } |
| if (currentNameSpaceDeviceNameSpacesMap_.size() > 0) { |
| deviceNameSpacesMap_.add(currentNameSpace_, |
| std::move(currentNameSpaceDeviceNameSpacesMap_)); |
| } |
| currentNameSpaceDeviceNameSpacesMap_ = cppbor::Map(); |
| |
| requestCountsRemaining_.erase(requestCountsRemaining_.begin()); |
| currentNameSpace_ = nameSpace; |
| newNamespace = true; |
| } |
| |
| // It's permissible to have an empty itemsRequest... but if non-empty you can |
| // only request what was specified in said itemsRequest. Enforce that. |
| if (itemsRequest_.size() > 0) { |
| const auto& it = requestedNameSpacesAndNames_.find(nameSpace); |
| if (it == requestedNameSpacesAndNames_.end()) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_NOT_IN_REQUEST_MESSAGE, |
| "Name space was not requested in startRetrieval")); |
| } |
| const set<string>& dataItemNames = it->second; |
| if (dataItemNames.find(name) == dataItemNames.end()) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_NOT_IN_REQUEST_MESSAGE, |
| "Data item name in name space was not requested in startRetrieval")); |
| } |
| } |
| |
| unsigned int newNamespaceNumEntries = 0; |
| if (newNamespace) { |
| if (expectedNumEntriesPerNamespace_.size() == 0) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "No more populated name spaces left to go through")); |
| } |
| newNamespaceNumEntries = expectedNumEntriesPerNamespace_[0]; |
| expectedNumEntriesPerNamespace_.erase(expectedNumEntriesPerNamespace_.begin()); |
| } |
| |
| // Access control is enforced in the secure hardware. |
| // |
| // ... except for STATUS_NOT_IN_REQUEST_MESSAGE, that's handled above (TODO: |
| // consolidate). |
| // |
| AccessCheckResult res = hwProxy_->startRetrieveEntryValue( |
| nameSpace, name, newNamespaceNumEntries, entrySize, accessControlProfileIds); |
| switch (res) { |
| case AccessCheckResult::kOk: |
| /* Do nothing. */ |
| break; |
| case AccessCheckResult::kFailed: |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Access control check failed (failed)")); |
| break; |
| case AccessCheckResult::kNoAccessControlProfiles: |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_NO_ACCESS_CONTROL_PROFILES, |
| "Access control check failed (no access control profiles)")); |
| break; |
| case AccessCheckResult::kUserAuthenticationFailed: |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_USER_AUTHENTICATION_FAILED, |
| "Access control check failed (user auth)")); |
| break; |
| case AccessCheckResult::kReaderAuthenticationFailed: |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_READER_AUTHENTICATION_FAILED, |
| "Access control check failed (reader auth)")); |
| break; |
| } |
| |
| currentName_ = name; |
| currentAccessControlProfileIds_ = accessControlProfileIds; |
| entryRemainingBytes_ = entrySize; |
| entryValue_.resize(0); |
| |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::retrieveEntryValue(const vector<uint8_t>& encryptedContent, |
| vector<uint8_t>* outContent) { |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| |
| optional<vector<uint8_t>> content = hwProxy_->retrieveEntryValue( |
| encryptedContent, currentNameSpace_, currentName_, currentAccessControlProfileIds_); |
| if (!content) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, "Error decrypting data")); |
| } |
| |
| size_t chunkSize = content.value().size(); |
| |
| if (chunkSize > entryRemainingBytes_) { |
| LOG(ERROR) << "Retrieved chunk of size " << chunkSize |
| << " is bigger than remaining space of size " << entryRemainingBytes_; |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Retrieved chunk is bigger than remaining space")); |
| } |
| |
| entryRemainingBytes_ -= chunkSize; |
| if (entryRemainingBytes_ > 0) { |
| if (chunkSize != IdentityCredentialStore::kGcmChunkSize) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Retrieved non-final chunk of size which isn't kGcmChunkSize")); |
| } |
| } |
| |
| entryValue_.insert(entryValue_.end(), content.value().begin(), content.value().end()); |
| |
| if (entryRemainingBytes_ == 0) { |
| auto [entryValueItem, _, message] = cppbor::parse(entryValue_); |
| if (entryValueItem == nullptr) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Retrieved data which is invalid CBOR")); |
| } |
| currentNameSpaceDeviceNameSpacesMap_.add(currentName_, std::move(entryValueItem)); |
| } |
| |
| *outContent = content.value(); |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::finishRetrievalWithSignature( |
| vector<uint8_t>* outMac, vector<uint8_t>* outDeviceNameSpaces, |
| vector<uint8_t>* outEcdsaSignature) { |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| |
| if (currentNameSpaceDeviceNameSpacesMap_.size() > 0) { |
| deviceNameSpacesMap_.add(currentNameSpace_, |
| std::move(currentNameSpaceDeviceNameSpacesMap_)); |
| } |
| vector<uint8_t> encodedDeviceNameSpaces = deviceNameSpacesMap_.encode(); |
| |
| if (encodedDeviceNameSpaces.size() != expectedDeviceNameSpacesSize_) { |
| LOG(ERROR) << "encodedDeviceNameSpaces is " << encodedDeviceNameSpaces.size() << " bytes, " |
| << "was expecting " << expectedDeviceNameSpacesSize_; |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| StringPrintf( |
| "Unexpected CBOR size %zd for encodedDeviceNameSpaces, was expecting %zd", |
| encodedDeviceNameSpaces.size(), expectedDeviceNameSpacesSize_) |
| .c_str())); |
| } |
| |
| optional<vector<uint8_t>> digestToBeMaced; |
| optional<vector<uint8_t>> signatureToBeSigned; |
| |
| // This relies on the fact that binder calls never pass a nullptr |
| // for out parameters. Hence if it's null here we know this was |
| // called from finishRetrieval() below. |
| if (outEcdsaSignature == nullptr) { |
| digestToBeMaced = hwProxy_->finishRetrieval(); |
| if (!digestToBeMaced) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Error generating digestToBeMaced")); |
| } |
| } else { |
| auto macAndSignature = hwProxy_->finishRetrievalWithSignature(); |
| if (!macAndSignature) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Error generating digestToBeMaced and signatureToBeSigned")); |
| } |
| digestToBeMaced = macAndSignature->first; |
| signatureToBeSigned = macAndSignature->second; |
| } |
| |
| // If the TA calculated a MAC (it might not have), format it as a COSE_Mac0 |
| // |
| // Size 0 means that the MAC isn't set. If it's set, it has to be 32 bytes. |
| optional<vector<uint8_t>> mac; |
| if (digestToBeMaced.value().size() != 0) { |
| if (digestToBeMaced.value().size() != 32) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_INVALID_DATA, |
| "Unexpected size for digestToBeMaced")); |
| } |
| mac = support::coseMacWithDigest(digestToBeMaced.value(), {} /* data */); |
| } |
| *outMac = mac.value_or(vector<uint8_t>({})); |
| |
| optional<vector<uint8_t>> signature; |
| if (signatureToBeSigned && signatureToBeSigned.value().size() != 0) { |
| signature = support::coseSignEcDsaWithSignature(signatureToBeSigned.value(), {}, // data |
| {}); // certificateChain |
| } |
| if (outEcdsaSignature != nullptr) { |
| *outEcdsaSignature = signature.value_or(vector<uint8_t>({})); |
| } |
| |
| *outDeviceNameSpaces = encodedDeviceNameSpaces; |
| |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::finishRetrieval(vector<uint8_t>* outMac, |
| vector<uint8_t>* outDeviceNameSpaces) { |
| return finishRetrievalWithSignature(outMac, outDeviceNameSpaces, nullptr); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::generateSigningKeyPair( |
| vector<uint8_t>* outSigningKeyBlob, Certificate* outSigningKeyCertificate) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| ndk::ScopedAStatus status = ensureHwProxy(); |
| if (!status.isOk()) { |
| return status; |
| } |
| |
| time_t now = time(NULL); |
| optional<pair<vector<uint8_t>, vector<uint8_t>>> pair = |
| hwProxy_->generateSigningKeyPair(docType_, now); |
| if (!pair) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error creating signingKey")); |
| } |
| |
| *outSigningKeyCertificate = Certificate(); |
| outSigningKeyCertificate->encodedCertificate = pair->first; |
| |
| *outSigningKeyBlob = pair->second; |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| ndk::ScopedAStatus IdentityCredential::updateCredential( |
| shared_ptr<IWritableIdentityCredential>* outWritableCredential) { |
| if (session_) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Cannot be called in a session")); |
| } |
| sp<SecureHardwareProvisioningProxy> provisioningHwProxy = |
| hwProxyFactory_->createProvisioningProxy(); |
| if (!provisioningHwProxy) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, "Error creating provisioning proxy")); |
| } |
| shared_ptr<WritableIdentityCredential> wc = |
| ndk::SharedRefBase::make<WritableIdentityCredential>( |
| provisioningHwProxy, docType_, testCredential_, hardwareInformation_); |
| if (!wc->initializeForUpdate(encryptedCredentialKeys_)) { |
| return ndk::ScopedAStatus(AStatus_fromServiceSpecificErrorWithMessage( |
| IIdentityCredentialStore::STATUS_FAILED, |
| "Error initializing WritableIdentityCredential for update")); |
| } |
| *outWritableCredential = wc; |
| return ndk::ScopedAStatus::ok(); |
| } |
| |
| } // namespace aidl::android::hardware::identity |