diff --git a/src/core/common/config.hpp b/src/core/common/config.hpp index 154889aac..954e5ffdf 100644 --- a/src/core/common/config.hpp +++ b/src/core/common/config.hpp @@ -162,6 +162,18 @@ #define AOS_CONFIG_TYPES_FILE_CHUNK_SIZE 64 * 1024 #endif +/** + * Chunk size used when streaming ciphertext into a multi-part PKCS11 decrypt operation (see + * aos::sm::imagemanager::BlobDecryptor). Deliberately smaller than AOS_CONFIG_TYPES_FILE_CHUNK_SIZE: some + * PKCS11 modules (e.g. a TEE-backed one with a limited shared memory budget) reject a single + * C_DecryptUpdate call above a certain size with CKR_DEVICE_MEMORY - empirically, one such module accepted + * up to ~34 KiB and rejected 36 KiB+. Override per platform if a target's PKCS11 module is known to allow + * more (or needs less). + */ +#ifndef AOS_CONFIG_IMAGEMANAGER_DECRYPT_CHUNK_SIZE +#define AOS_CONFIG_IMAGEMANAGER_DECRYPT_CHUNK_SIZE 16 * 1024 +#endif + /** * File system mount type len. */ diff --git a/src/core/common/crypto/itf/privkey.hpp b/src/core/common/crypto/itf/privkey.hpp index e2945c9a8..f71d3ec26 100644 --- a/src/core/common/crypto/itf/privkey.hpp +++ b/src/core/common/crypto/itf/privkey.hpp @@ -8,10 +8,12 @@ #define AOS_CORE_COMMON_CRYPTO_ITF_PRIVKEY_HPP_ #include +#include #include #include #include +#include "aes.hpp" #include "hash.hpp" namespace aos::crypto { @@ -77,10 +79,90 @@ struct OAEPDecryptionOptions { Hash mHash; }; +/** + * AES-GCM decryption options. + */ +struct GCMDecryptionOptions { + /** + * GCM initialization vector (nonce): AESCipherItf::cGCMIVSize (12) bytes. + */ + StaticArray mIV; +}; + +/** + * AES-CTR decryption options. + */ +struct CTRDecryptionOptions { + /** + * Initial counter block: AESCipherItf::cBlockSize (16) bytes, incremented as a single big-endian 128-bit + * value for each subsequent block. + */ + StaticArray mCounter; +}; + /** * Decryption options. */ -using DecryptionOptions = Variant; +using DecryptionOptions + = Variant; + +/** + * Supplies data chunks on demand to a streaming operation (see PrivateKeyItf::StreamDecrypt), so the + * whole input never needs to be held in memory at once. The provider owns the chunk buffer itself (sized + * and allocated however its own implementation sees fit) rather than requiring the caller to supply one: + * on a stack-constrained target, a caller-supplied buffer sized for a whole chunk (tens of KiB) can blow a + * function's stack budget, whereas a concrete provider (e.g. one backed by a file) can size its buffer via + * whatever AllocatorItf it already has. + */ +class ChunkProviderItf { +public: + /** + * Returns the next chunk of data. The returned array is only valid until the next call to + * NextChunk or until this provider is destroyed - callers must consume it before calling again. + * + * @return RetWithError>. ErrorEnum::eEOF (with an empty array) once no more data + * remains; a call that delivers the last chunk returns ErrorEnum::eNone, even if that chunk is + * short. + */ + virtual RetWithError> NextChunk() = 0; + + /** + * Destroys object instance. + */ + virtual ~ChunkProviderItf() = default; +}; + +/** + * Receives data chunks produced by a streaming operation (see PrivateKeyItf::StreamDecrypt), so the caller + * can handle output (e.g. write it to a file) as it comes back instead of collecting it into a single array. + * Like ChunkProviderItf, the receiver owns the buffer output is written into: the streaming operation writes + * each produced chunk into GetBuffer() and then hands it back via OnChunk. + */ +class ChunkReceiverItf { +public: + /** + * Returns the buffer the next output chunk is written into. Its MaxSize bounds how much output a single + * step of the operation may produce: many PKCS11 tokens only release AEAD-decrypted data all at once, + * at the very end, so for those it must have capacity for the whole output. + * + * @return Array&. + */ + virtual Array& GetBuffer() = 0; + + /** + * Handles the next output chunk. chunk is a view into GetBuffer() and is only valid until this call + * returns. Never called with an empty chunk. + * + * @param chunk output chunk. + * @return Error. Any error aborts the streaming operation and is returned to its caller. + */ + virtual Error OnChunk(const Array& chunk) = 0; + + /** + * Destroys object instance. + */ + virtual ~ChunkReceiverItf() = default; +}; /** * Public key interface. @@ -141,6 +223,33 @@ class PrivateKeyItf { virtual Error Decrypt(const Array& cipher, const DecryptionOptions& options, Array& result) const = 0; + /** + * Decrypts a cipher message supplied incrementally by chunkProvider, so the whole ciphertext + * doesn't need to be held in memory at once - only whatever chunk size chunkProvider hands back + * at a time. Decrypted data is handed to chunkReceiver as it becomes available. This doesn't + * necessarily bound *output* memory the same way: many PKCS11 tokens only release AEAD-decrypted + * data once the authentication tag has been verified, all at once, at the very end, so + * chunkReceiver's buffer must still have capacity for the whole plaintext regardless of how the + * input was chunked (see pkcs11::AESPrivateKey for the concrete behavior). Note that, for tokens + * that do release data early, chunkReceiver may get plaintext before the tag is verified: it must + * not trust it until StreamDecrypt returns successfully. Default implementation returns + * ErrorEnum::eNotSupported; only override it where streaming input actually helps. + * + * @param chunkProvider supplies the cipher message in chunks. + * @param options decryption options. + * @param chunkReceiver receives the decoded message in chunks. + * @return Error. + */ + virtual Error StreamDecrypt( + ChunkProviderItf& chunkProvider, const DecryptionOptions& options, ChunkReceiverItf& chunkReceiver) const + { + (void)chunkProvider; + (void)options; + (void)chunkReceiver; + + return ErrorEnum::eNotSupported; + } + /** * Destroys object instance. */ diff --git a/src/core/common/crypto/mbedtls/cryptoprovider.cpp b/src/core/common/crypto/mbedtls/cryptoprovider.cpp index b90966484..2eaefe5e4 100644 --- a/src/core/common/crypto/mbedtls/cryptoprovider.cpp +++ b/src/core/common/crypto/mbedtls/cryptoprovider.cpp @@ -1874,6 +1874,20 @@ Error MbedTLSCryptoProvider::MbedTLSRSAPrivKey::Decrypt( return ErrorEnum::eNone; } + Error Visit(const GCMDecryptionOptions& opts) const + { + (void)opts; + + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); + } + + Error Visit(const CTRDecryptionOptions& opts) const + { + (void)opts; + + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); + } + mbedtls_pk_context* mPrivKey = nullptr; mbedtls_ctr_drbg_context* mDRBG = nullptr; diff --git a/src/core/common/crypto/openssl/cryptoprovider.cpp b/src/core/common/crypto/openssl/cryptoprovider.cpp index 7dcd967f2..b9bf8f33d 100644 --- a/src/core/common/crypto/openssl/cryptoprovider.cpp +++ b/src/core/common/crypto/openssl/cryptoprovider.cpp @@ -2790,6 +2790,20 @@ Error OpenSSLCryptoProvider::OpenSSLRSAPrivKey::Decrypt( return ErrorEnum::eNone; } + Error Visit(const GCMDecryptionOptions& opts) const + { + (void)opts; + + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); + } + + Error Visit(const CTRDecryptionOptions& opts) const + { + (void)opts; + + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); + } + private: EVP_PKEY* mPrivKey = nullptr; const Array& mCipher; diff --git a/src/core/common/crypto/tests/certloader.cpp b/src/core/common/crypto/tests/certloader.cpp index 26015d6aa..9546ae2ba 100644 --- a/src/core/common/crypto/tests/certloader.cpp +++ b/src/core/common/crypto/tests/certloader.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -70,6 +71,49 @@ class CertloaderTest : public Test { .IsNone()); } + // Provisions a non-extractable CKO_SECRET_KEY (AES) object: the posture LoadPrivKeyByURL/FindPrivateKey + // actually search for (alongside CKO_PRIVATE_KEY), and the only one production provisioning is expected + // to create. The key's own value is never read back by anything under test here (crypto::PrivateKeyItf + // never exposes it). + void WriteSecretKeyObject(const Array& id, const String& label, const Array& value) + { + Error err = ErrorEnum::eNone; + SharedPtr session; + + Tie(session, err) = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + CK_OBJECT_CLASS keyClass = CKO_SECRET_KEY; + CK_KEY_TYPE keyType = CKK_AES; + CK_BBOOL trueVal = CK_TRUE; + CK_BBOOL falseVal = CK_FALSE; + + StaticArray templ; + + auto pushBytes = [&](pkcs11::AttributeType type, const void* data, size_t size) { + ASSERT_TRUE( + templ.PushBack({type, Array(reinterpret_cast(const_cast(data)), size)}) + .IsNone()); + }; + + pushBytes(CKA_CLASS, &keyClass, sizeof(keyClass)); + pushBytes(CKA_KEY_TYPE, &keyType, sizeof(keyType)); + pushBytes(CKA_TOKEN, &trueVal, sizeof(trueVal)); + pushBytes(CKA_PRIVATE, &trueVal, sizeof(trueVal)); + pushBytes(CKA_EXTRACTABLE, &falseVal, sizeof(falseVal)); + pushBytes(CKA_SENSITIVE, &trueVal, sizeof(trueVal)); + pushBytes(CKA_ID, id.Get(), id.Size()); + pushBytes(CKA_LABEL, label.Get(), label.Size()); + ASSERT_TRUE( + templ.PushBack({CKA_VALUE, Array(const_cast(value.Get()), value.Size())}).IsNone()); + + pkcs11::ObjectHandle handle = 0; + Error createErr = ErrorEnum::eNone; + + Tie(handle, createErr) = session->CreateObject(templ); + ASSERT_TRUE(createErr.IsNone()); + } + void GeneratePrivateKey(const Array& id) { Error err; @@ -339,4 +383,43 @@ TEST_F(CertloaderTest, FindCertificatesFromFile) EXPECT_EQ(std::string(issuer.CStr()), std::string("CN=Aos Cloud")); } +TEST_F(CertloaderTest, FindPKCS11SecretKey) +{ + constexpr uint8_t id[] = {0xDD, 0xEE, 0xFF}; + constexpr uint8_t value[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20}; + + WriteSecretKeyObject(Array(id, ArraySize(id)), "aos-layer-key", Array(value, ArraySize(value))); + + const auto url = "pkcs11:token=cryptoutils;object=aos-layer-key;id=%DD%EE%FF?module-path=" SOFTHSM2_LIB + "&pin-source=" + + std::string(mPINSource); + + auto [key, err] = mCertLoader.LoadPrivKeyByURL(url.c_str()); + + // the key's own value is never exposed: success and a non-null handle is all there is to check here. + // Actually decrypting with it is covered by the pkcs11/sm/imagemanager round-trip tests. + ASSERT_TRUE(err.IsNone()); + EXPECT_TRUE(key); +} + +TEST_F(CertloaderTest, FindPKCS11SecretKeyNotFound) +{ + constexpr uint8_t id[] = {0xDD, 0xEE, 0xFF}; + constexpr uint8_t value[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20}; + + WriteSecretKeyObject(Array(id, ArraySize(id)), "aos-layer-key", Array(value, ArraySize(value))); + + // matches neither the secret key above (wrong label) nor any RSA/ECDSA key pair. + const auto url = "pkcs11:token=cryptoutils;object=no-such-label;id=%DD%EE%FF?module-path=" SOFTHSM2_LIB + "&pin-source=" + + std::string(mPINSource); + + auto [key, err] = mCertLoader.LoadPrivKeyByURL(url.c_str()); + + EXPECT_TRUE(err.Is(ErrorEnum::eNotFound)); + EXPECT_FALSE(key); +} + } // namespace aos::crypto diff --git a/src/core/common/crypto/tests/cryptoprovider.cpp b/src/core/common/crypto/tests/cryptoprovider.cpp index 41dfea2df..dbaf358cf 100644 --- a/src/core/common/crypto/tests/cryptoprovider.cpp +++ b/src/core/common/crypto/tests/cryptoprovider.cpp @@ -1256,6 +1256,26 @@ TEST_P(CryptoProviderTest, VerifyCACert) ASSERT_TRUE(ValidateCACert(cert).IsNone()); } +TEST_P(CryptoProviderTest, RSAPrivKeyRejectsAESDecryptionOptions) +{ + StaticString pem; + + ASSERT_TRUE(fs::ReadFileToString(TEST_CERTIFICATES_DIR "/client.key", pem).IsNone()); + + auto [key, err] = mCryptoProvider->PEMToX509PrivKey(pem); + ASSERT_TRUE(err.IsNone()); + ASSERT_TRUE(key); + + StaticArray cipher; + StaticArray result; + + ASSERT_TRUE(cipher.Resize(cipher.MaxSize(), 0).IsNone()); + + // AES-GCM/AES-CTR options only apply to a symmetric key: an RSA key rejects them without decrypting anything. + EXPECT_TRUE(key->Decrypt(cipher, DecryptionOptions {GCMDecryptionOptions {}}, result).Is(ErrorEnum::eNotSupported)); + EXPECT_TRUE(key->Decrypt(cipher, DecryptionOptions {CTRDecryptionOptions {}}, result).Is(ErrorEnum::eNotSupported)); +} + TEST_P(CryptoProviderTest, VerifyLeafCert) { StaticString buff; diff --git a/src/core/common/ocispec/itf/imagespec.hpp b/src/core/common/ocispec/itf/imagespec.hpp index 536652cf7..c907e5b1f 100644 --- a/src/core/common/ocispec/itf/imagespec.hpp +++ b/src/core/common/ocispec/itf/imagespec.hpp @@ -57,6 +57,7 @@ constexpr auto cRootfsTypeLen = AOS_CONFIG_OCISPEC_ROOTFS_TYPE_LEN; */ constexpr auto cMediaTypeLayerTar = "application/vnd.oci.image.layer.v1.tar"; constexpr auto cMediaTypeLayerTarGZip = "application/vnd.oci.image.layer.v1.tar+gzip"; +constexpr auto cMediaTypeLayerTarGZipEncrypted = "application/vnd.aos.image.layer.enc.v1.aes256gcm+tar+gz"; constexpr auto cMediaTypeEmptyBlob = "application/vnd.oci.empty.v1+json"; constexpr auto cMediaTypeComponentFullTarGZip = "application/vnd.aos.image.component.full.v1+gzip"; constexpr auto cMediaTypeComponentFullSquashfs = "application/vnd.aos.image.component.full.v1+squashfs"; diff --git a/src/core/common/pkcs11/cryptoki/pkcs11.h b/src/core/common/pkcs11/cryptoki/pkcs11.h index 30530a2e9..865bc710c 100644 --- a/src/core/common/pkcs11/cryptoki/pkcs11.h +++ b/src/core/common/pkcs11/cryptoki/pkcs11.h @@ -877,6 +877,11 @@ typedef struct CK_GCM_PARAMS { unsigned long ulTagBits; } CK_GCM_PARAMS; +typedef struct CK_AES_CTR_PARAMS { + unsigned long ulCounterBits; + unsigned char cb[16]; +} CK_AES_CTR_PARAMS; + typedef unsigned long ck_rv_t; diff --git a/src/core/common/pkcs11/pkcs11.cpp b/src/core/common/pkcs11/pkcs11.cpp index 4c897b5c2..4f45f44b5 100644 --- a/src/core/common/pkcs11/pkcs11.cpp +++ b/src/core/common/pkcs11/pkcs11.cpp @@ -760,6 +760,31 @@ Error SessionContext::Decrypt( return result.Resize(resultSize); } +Error SessionContext::DecryptMultiPart(CK_MECHANISM_PTR mechanism, ObjectHandle privKey, + crypto::ChunkProviderItf& chunkProvider, crypto::ChunkReceiverItf& chunkReceiver) const +{ + LockGuard lock {mMutex}; + + if (auto err = DecryptInit(mechanism, privKey); !err.IsNone()) { + return err; + } + + // Raw C_DecryptUpdate/C_DecryptFinal writes go straight into the receiver's buffer storage, bounded by its + // MaxSize: the buffer itself is never resized (growing an Array zero-fills it), only a view of the bytes + // actually produced is handed back to the receiver. + auto& buffer = chunkReceiver.GetBuffer(); + + if (auto err = DecryptParts(chunkProvider, chunkReceiver, buffer); !err.IsNone()) { + // the operation may still be active (e.g. the provider or receiver failed, not the token): terminate it, + // so the next C_DecryptInit on this session isn't rejected with CKR_OPERATION_ACTIVE. + AbortDecrypt(buffer); + + return err; + } + + return ErrorEnum::eNone; +} + SessionHandle SessionContext::GetHandle() const { LockGuard lock {mMutex}; @@ -844,6 +869,119 @@ Error SessionContext::Decrypt(const Array& data, CK_BYTE_PTR result, CK return ErrorEnum::eNone; } +Error SessionContext::DecryptParts( + crypto::ChunkProviderItf& chunkProvider, crypto::ChunkReceiverItf& chunkReceiver, Array& buffer) const +{ + auto deliver = [&chunkReceiver, &buffer](CK_ULONG size) -> Error { + LOG_DBG() << "Delivering decrypted chunk" << Log::Field("size", size); + + if (size == 0) { + return ErrorEnum::eNone; + } + + return chunkReceiver.OnChunk(Array(buffer.Get(), size)); + }; + + size_t i = 0; + + while (true) { + auto [chunk, err] = chunkProvider.NextChunk(); + if (err.Is(ErrorEnum::eEOF)) { + break; + } + + LOG_DBG() << "Received chunk" << Log::Field("index", i) << Log::Field("size", chunk.Size()); + + if (!err.IsNone()) { + return err; + } + + i++; + + CK_ULONG outSize = buffer.MaxSize(); + + err = DecryptUpdate(chunk, buffer.Get(), &outSize); + if (!err.IsNone()) { + return ConvertMultiPartError(err); + } + + err = deliver(outSize); + if (!err.IsNone()) { + return err; + } + } + + CK_ULONG finalSize = buffer.MaxSize(); + + if (auto err = DecryptFinal(buffer.Get(), &finalSize); !err.IsNone()) { + return ConvertMultiPartError(err); + } + + return deliver(finalSize); +} + +Error SessionContext::ConvertMultiPartError(const Error& err) +{ + // Some PKCS11 modules don't support multi-part operations for a mechanism at all and only say so once data is + // pushed through C_DecryptUpdate/C_DecryptFinal, not at C_DecryptInit. Anything else (e.g. CKR_DEVICE_MEMORY + // for a chunk too large for the token) is returned as is, so the actual cause isn't hidden. + if (err.Errno() == static_cast(CKR_FUNCTION_NOT_SUPPORTED) + || err.Errno() == static_cast(CKR_MECHANISM_INVALID)) { + return AOS_ERROR_WRAP(Error(ErrorEnum::eNotSupported, "multi-part decryption not supported by token")); + } + + return err; +} + +void SessionContext::AbortDecrypt(Array& buffer) const +{ + // C_DecryptFinal terminates the operation unless it fails with CKR_BUFFER_TOO_SMALL; if no operation is active + // any more (a failed C_DecryptUpdate/C_DecryptFinal already terminated it), it's a harmless + // CKR_OPERATION_NOT_INITIALIZED. Whatever it outputs is discarded. + if (CK_ULONG size = buffer.MaxSize(); + DecryptFinal(buffer.Get(), &size).Errno() != static_cast(CKR_BUFFER_TOO_SMALL)) { + return; + } + + // PKCS#11 3.0: C_DecryptInit with a NULL mechanism terminates the active decryption operation. Older tokens may + // ignore it, but CTR never holds output back for C_DecryptFinal, so it isn't needed for the blob decryption path. + if (mFunctionList && mFunctionList->C_DecryptInit) { + (void)mFunctionList->C_DecryptInit(mHandle, nullptr, CK_INVALID_HANDLE); + } +} + +Error SessionContext::DecryptUpdate(const Array& data, CK_BYTE_PTR result, CK_ULONG_PTR resultSize) const +{ + if (!mFunctionList || !mFunctionList->C_DecryptUpdate) { + return ErrorEnum::eWrongState; + } + + LOG_DBG() << "Calling C_DecryptUpdate" << Log::Field("dataSize", data.Size()); + + if (CK_RV rv = mFunctionList->C_DecryptUpdate( + mHandle, const_cast(data.Get()), data.Size(), result, resultSize); // NOSONAR cpp:M23_090 + rv != CKR_OK) { + LOG_ERR() << "C_DecryptUpdate failed" << Log::Field("rv", static_cast(rv)); + + return static_cast(rv); + } + + return ErrorEnum::eNone; +} + +Error SessionContext::DecryptFinal(CK_BYTE_PTR result, CK_ULONG_PTR resultSize) const +{ + if (!mFunctionList || !mFunctionList->C_DecryptFinal) { + return ErrorEnum::eWrongState; + } + + if (CK_RV rv = mFunctionList->C_DecryptFinal(mHandle, result, resultSize); rv != CKR_OK) { + return static_cast(rv); + } + + return ErrorEnum::eNone; +} + Error SessionContext::FindObjectsInit(const Array& templ) const { if (!mFunctionList || !mFunctionList->C_FindObjectsInit) { @@ -1169,6 +1307,38 @@ RetWithError Utils::FindPrivateKey(const Array& id, const S LOG_DBG() << "Find private key: id=" << idStr << ", label=" << label; + // A symmetric AES key has no public part, so it never matches the CKO_PRIVATE_KEY/CKO_PUBLIC_KEY pairing + // search below; try it first as a CKO_SECRET_KEY object with the same id/label. + CK_OBJECT_CLASS secretKeyClass = CKO_SECRET_KEY; + CK_KEY_TYPE keyTypeAES = CKK_AES; + + StaticArray secretKeyTempl; + + (void)secretKeyTempl.PushBack({CKA_CLASS, ConvertToAttributeValue(secretKeyClass)}); + (void)secretKeyTempl.PushBack({CKA_KEY_TYPE, ConvertToAttributeValue(keyTypeAES)}); + (void)secretKeyTempl.PushBack({CKA_ID, id}); + (void)secretKeyTempl.PushBack({CKA_LABEL, ConvertToAttributeValue(label)}); + + StaticArray secretKeys; + + // SessionContext::FindObjects itself returns eNotFound (not just an empty result) when nothing matches: + // that's the expected, common case here (most keys are RSA/ECDSA), not a real failure, so it must not + // short-circuit the CKO_PRIVATE_KEY/CKO_PUBLIC_KEY search below. + if (auto err = mSession->FindObjects(secretKeyTempl, secretKeys); !err.IsNone() && !err.Is(ErrorEnum::eNotFound)) { + return {{}, AOS_ERROR_WRAP(err)}; + } + + if (!secretKeys.IsEmpty()) { + // even after pinning class/key type/id/label, a collision between two such objects is still possible + // and would silently pick an arbitrary one; treat that as an error instead of guessing. + if (secretKeys.Size() > 1) { + return { + {}, AOS_ERROR_WRAP(Error(ErrorEnum::eInvalidArgument, "id/label matches more than one secret key"))}; + } + + return ExportPrivateKey(secretKeys[0], 0, keyTypeAES); + } + constexpr auto cSingleAttribute = 1; CK_OBJECT_CLASS privKeyClass = CKO_PRIVATE_KEY; @@ -1232,6 +1402,11 @@ Error Utils::DeletePrivateKey(const PrivateKey& key) return err; } + // a symmetric (CKO_SECRET_KEY) key has no public object: its pub handle is CK_INVALID_HANDLE. + if (key.GetPubHandle() == CK_INVALID_HANDLE) { + return ErrorEnum::eNone; + } + return mSession->DestroyObject(key.GetPubHandle()); } @@ -1426,6 +1601,40 @@ RetWithError Utils::ExportPrivateKey( ObjectHandle privKeyHandle, ObjectHandle pubKeyHandle, CK_KEY_TYPE keyType) { switch (keyType) { + case CKK_AES: { + // AESPrivateKey is AES-256 only: don't silently accept a weaker key that happens to share the id/label. + StaticArray, 1> attrValues; + StaticArray attrTypes; + StaticArray valueLen; + + (void)attrTypes.PushBack(CKA_VALUE_LEN); + (void)attrValues.PushBack(valueLen); + + if (auto err = mSession->GetAttributeValues(privKeyHandle, attrTypes, attrValues); !err.IsNone()) { + return {{}, AOS_ERROR_WRAP(err)}; + } + + CK_ULONG keySize = 0; + + if (attrValues[0].Size() != sizeof(keySize)) { + return {{}, AOS_ERROR_WRAP(Error(ErrorEnum::eFailed, "unexpected CKA_VALUE_LEN size"))}; + } + + (void)memcpy(&keySize, attrValues[0].Get(), sizeof(keySize)); + + if (keySize != AESPrivateKey::cKeySize) { + return {{}, AOS_ERROR_WRAP(Error(ErrorEnum::eInvalidArgument, "AES key is not 256 bits"))}; + } + + // no public part to look up: pubKeyHandle is unused (0) for a symmetric key. + auto cryptoKey = MakeShared(&mAllocator, mSession, privKeyHandle); + if (!cryptoKey) { + return {{}, AOS_ERROR_WRAP(ErrorEnum::eNoMemory)}; + } + + return {PrivateKey {privKeyHandle, 0, cryptoKey}, ErrorEnum::eNone}; + } + case CKK_RSA: { StaticArray, cObjectAttributesCount> attrValues; StaticArray attrTypes; diff --git a/src/core/common/pkcs11/pkcs11.hpp b/src/core/common/pkcs11/pkcs11.hpp index 1e6d37437..224dee403 100644 --- a/src/core/common/pkcs11/pkcs11.hpp +++ b/src/core/common/pkcs11/pkcs11.hpp @@ -446,6 +446,28 @@ class SessionContext : private NonCopyable { Error Decrypt( CK_MECHANISM_PTR mechanism, ObjectHandle privKey, const Array& data, Array& result) const; + /** + * Decrypts data supplied incrementally by chunkProvider using a multi-part PKCS11 operation, so + * the whole ciphertext never needs to be held in memory at once. Whatever plaintext each + * C_DecryptUpdate/C_DecryptFinal call releases is written into chunkReceiver's buffer and handed + * to chunkReceiver as it comes back; many PKCS11 modules (SoftHSM2 included, verified empirically + * against CKM_AES_GCM) only release AEAD-decrypted data once the tag has been checked, at + * C_DecryptFinal, all at once - so chunkReceiver's buffer must have capacity for the whole + * plaintext regardless of how input was chunked. + * + * @param mechanism mechanism used to decrypt. + * @param privKey the handle of the private/secret key. + * @param chunkProvider supplies ciphertext chunks (and owns their storage). + * @param chunkReceiver receives decrypted chunks (and owns their storage). + * @return Error. ErrorEnum::eNotSupported if C_DecryptUpdate/C_DecryptFinal fails with + * CKR_FUNCTION_NOT_SUPPORTED or CKR_MECHANISM_INVALID: some PKCS11 modules don't support multi-part + * operations for a mechanism at all, and only say so once data is pushed through, not at C_DecryptInit. + * Any other token error (e.g. CKR_DEVICE_MEMORY for a too large chunk) is returned as is. On any error + * the decrypt operation is terminated, so the session is ready for a new one. + */ + Error DecryptMultiPart(CK_MECHANISM_PTR mechanism, ObjectHandle privKey, crypto::ChunkProviderItf& chunkProvider, + crypto::ChunkReceiverItf& chunkReceiver) const; + /** * Returns session handle. * @@ -471,6 +493,13 @@ class SessionContext : private NonCopyable { Error DecryptInit(CK_MECHANISM_PTR mechanism, ObjectHandle privKey) const; Error Decrypt(const Array& data, CK_BYTE_PTR result, CK_ULONG_PTR resultSize) const; + Error DecryptParts( + crypto::ChunkProviderItf& chunkProvider, crypto::ChunkReceiverItf& chunkReceiver, Array& buffer) const; + void AbortDecrypt(Array& buffer) const; + + static Error ConvertMultiPartError(const Error& err); + Error DecryptUpdate(const Array& data, CK_BYTE_PTR result, CK_ULONG_PTR resultSize) const; + Error DecryptFinal(CK_BYTE_PTR result, CK_ULONG_PTR resultSize) const; Error FindObjectsInit(const Array& templ) const; Error FindObjects(Array& objects) const; @@ -751,7 +780,10 @@ class Utils { const Array& id, const String& label, EllipticCurve curve); /** - * Retrieves a previously created asymmetric key pair. + * Retrieves a previously created key by id/label: an asymmetric (RSA/ECDSA) key pair, or a CKO_SECRET_KEY + * (AES) object wrapped as an aos::crypto::PrivateKeyItf whose GetPublic/Sign are simply not supported + * (AESPrivateKey), so callers that only need PrivateKeyItf::Decrypt don't need to know which one they got. + * The returned PrivateKey's pub handle is 0 for the AES case. * * @param id key id. * @param label key label. diff --git a/src/core/common/pkcs11/privatekey.cpp b/src/core/common/pkcs11/privatekey.cpp index 202072a26..8127b6aba 100644 --- a/src/core/common/pkcs11/privatekey.cpp +++ b/src/core/common/pkcs11/privatekey.cpp @@ -151,6 +151,20 @@ RetWithError PCKS11RSAMechConverter::Visit(const crypto::OAEPDecry return mech; } +RetWithError PCKS11RSAMechConverter::Visit(const crypto::GCMDecryptionOptions& options) const +{ + (void)options; + + return {{}, AOS_ERROR_WRAP(ErrorEnum::eNotSupported)}; +} + +RetWithError PCKS11RSAMechConverter::Visit(const crypto::CTRDecryptionOptions& options) const +{ + (void)options; + + return {{}, AOS_ERROR_WRAP(ErrorEnum::eNotSupported)}; +} + /*********************************************************************************************************************** * PKCS11ECDSAPrivateKey **********************************************************************************************************************/ @@ -181,4 +195,200 @@ Error PKCS11ECDSAPrivateKey::Sign( return mSession->Sign(&mechanism, mPrivKeyHandle, digest, signature); } +/*********************************************************************************************************************** + * PKCS11AESMechConverter + **********************************************************************************************************************/ + +RetWithError PKCS11AESMechConverter::Visit(const crypto::PKCS1v15DecryptionOptions& options) const +{ + (void)options; + + return {{}, AOS_ERROR_WRAP(ErrorEnum::eNotSupported)}; +} + +RetWithError PKCS11AESMechConverter::Visit(const crypto::OAEPDecryptionOptions& options) const +{ + (void)options; + + return {{}, AOS_ERROR_WRAP(ErrorEnum::eNotSupported)}; +} + +RetWithError PKCS11AESMechConverter::Visit(const crypto::GCMDecryptionOptions& options) const +{ + if (options.mIV.Size() != crypto::AESCipherItf::cGCMIVSize) { + return {{}, AOS_ERROR_WRAP(ErrorEnum::eInvalidArgument)}; + } + + mGCMParams.pIv = const_cast(options.mIV.Get()); // NOSONAR cpp:M23_090 + mGCMParams.ulIvLen = static_cast(options.mIV.Size()); + mGCMParams.ulIvBits = static_cast(options.mIV.Size() * 8); + mGCMParams.ulTagBits = crypto::AESCipherItf::cGCMTagSize * 8; + + return CK_MECHANISM {CKM_AES_GCM, &mGCMParams, sizeof(mGCMParams)}; +} + +RetWithError PKCS11AESMechConverter::Visit(const crypto::CTRDecryptionOptions& options) const +{ + if (options.mCounter.Size() != sizeof(mCTRParams.cb)) { + return {{}, AOS_ERROR_WRAP(ErrorEnum::eInvalidArgument)}; + } + + mCTRParams.ulCounterBits = mCTRCounterBits; + (void)memcpy(mCTRParams.cb, options.mCounter.Get(), sizeof(mCTRParams.cb)); + + return CK_MECHANISM {CKM_AES_CTR, &mCTRParams, sizeof(mCTRParams)}; +} + +/*********************************************************************************************************************** + * AESPrivateKey + **********************************************************************************************************************/ + +AESPrivateKey::AESPrivateKey(const SharedPtr& session, ObjectHandle keyHandle) + : mSession(session) + , mKeyHandle(keyHandle) +{ + LOG_DBG() << "Create AES secret key"; +} + +const crypto::PublicKeyItf& AESPrivateKey::GetPublic() const +{ + return mNoPublicKey; +} + +Error AESPrivateKey::Sign( + const Array& digest, const crypto::SignOptions& options, Array& signature) const +{ + (void)digest; + (void)options; + (void)signature; + + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); +} + +Error AESPrivateKey::Decrypt( + const Array& cipher, const crypto::DecryptionOptions& options, Array& result) const +{ + return WithMechanism( + options, [&](CK_MECHANISM& mech) { return mSession->Decrypt(&mech, mKeyHandle, cipher, result); }); +} + +Error AESPrivateKey::StreamDecrypt(crypto::ChunkProviderItf& chunkProvider, const crypto::DecryptionOptions& options, + crypto::ChunkReceiverItf& chunkReceiver) const +{ + return WithMechanism(options, [&](CK_MECHANISM& mech) { + return mSession->DecryptMultiPart(&mech, mKeyHandle, chunkProvider, chunkReceiver); + }); +} + +template +Error AESPrivateKey::WithMechanism(const crypto::DecryptionOptions& options, Op op) const +{ + // both outlive op: the mechanism's parameters point into the visitor that produced it. + PKCS11AESMechConverter visitor(cCTRCounterBits); + PKCS11AESMechConverter fallbackVisitor(cOPTEECTRCounterBits); + + auto [mech, err] = options.ApplyVisitor(visitor); + if (!err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + if (mech.mechanism == CKM_AES_CTR) { + CK_ULONG counterBits = 0; + + if (err = GetCTRCounterBits(counterBits); !err.IsNone()) { + return err; + } + + if (counterBits == cOPTEECTRCounterBits) { + Tie(mech, err) = options.ApplyVisitor(fallbackVisitor); + if (!err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + } + } + + return op(mech); +} + +Error AESPrivateKey::GetCTRCounterBits(CK_ULONG& counterBits) const +{ + constexpr auto cBlockSize = crypto::AESCipherItf::cBlockSize; + + LockGuard lock {mMutex}; + + if (mCTRCounterBits == 0) { + // probed with a single-shot decrypt of one block, so whatever the token rejects, no caller data has been + // consumed yet: the operation that needs the counter width only starts once it is known. + CK_AES_CTR_PARAMS params {cCTRCounterBits, {}}; + CK_MECHANISM mech {CKM_AES_CTR, ¶ms, sizeof(params)}; + + StaticArray zeros; + StaticArray block; + + if (auto err = zeros.Resize(zeros.MaxSize(), 0); !err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + if (auto err = mSession->Decrypt(&mech, mKeyHandle, zeros, block); err.IsNone()) { + mCTRCounterBits = cCTRCounterBits; + } else if (err.Errno() == static_cast(CKR_MECHANISM_PARAM_INVALID)) { + LOG_DBG() << "Token rejected CTR counter bits, falling back" + << Log::Field("counterBits", cOPTEECTRCounterBits); + + if (err = VerifyFallbackCounter(); !err.IsNone()) { + return err; + } + + mCTRCounterBits = cOPTEECTRCounterBits; + } else { + return AOS_ERROR_WRAP(err); + } + } + + counterBits = mCTRCounterBits; + + return ErrorEnum::eNone; +} + +Error AESPrivateKey::VerifyFallbackCounter() const +{ + constexpr auto cBlockSize = crypto::AESCipherItf::cBlockSize; + + // T = 0^96 || 0xFFFFFFFF: its successor T + 1 = 0^95 1 || 0^32 needs a carry across the low 32 bits, while a + // 1-bit counter would wrap to T - 1 instead. Neither block is a keystream block of any GCM message with a + // 96-bit IV within GCM's length limit, and AES outputs don't reveal the key. + CK_AES_CTR_PARAMS params {cOPTEECTRCounterBits, {}}; + CK_MECHANISM mech {CKM_AES_CTR, ¶ms, sizeof(params)}; + + (void)memset(params.cb + cBlockSize - 4, 0xff, 4); + + StaticArray zeros; + StaticArray twoBlocks; + StaticArray nextBlock; + + if (auto err = zeros.Resize(zeros.MaxSize(), 0); !err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + if (auto err = mSession->Decrypt(&mech, mKeyHandle, zeros, twoBlocks); !err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + (void)memset(params.cb, 0, sizeof(params.cb)); + params.cb[cBlockSize - 5] = 0x01; + + if (auto err = mSession->Decrypt(&mech, mKeyHandle, Array(zeros.Get(), cBlockSize), nextBlock); + !err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + if (twoBlocks.Size() != cBlockSize * 2 || nextBlock.Size() != cBlockSize + || memcmp(twoBlocks.Get() + cBlockSize, nextBlock.Get(), cBlockSize) != 0) { + return AOS_ERROR_WRAP( + Error(ErrorEnum::eNotSupported, "token's CTR counter doesn't span the whole counter block")); + } + + return ErrorEnum::eNone; +} + } // namespace aos::pkcs11 diff --git a/src/core/common/pkcs11/privatekey.hpp b/src/core/common/pkcs11/privatekey.hpp index 37286383e..55a018885 100644 --- a/src/core/common/pkcs11/privatekey.hpp +++ b/src/core/common/pkcs11/privatekey.hpp @@ -104,6 +104,20 @@ struct PCKS11RSAMechConverter : public StaticVisitor> */ RetWithError Visit(const crypto::OAEPDecryptionOptions& options) const; + /** + * Rejects a GCM option: not applicable to an RSA key. + * + * @return RetWithError. + */ + RetWithError Visit(const crypto::GCMDecryptionOptions& options) const; + + /** + * Rejects a CTR option: not applicable to an RSA key. + * + * @return RetWithError. + */ + RetWithError Visit(const crypto::CTRDecryptionOptions& options) const; + private: mutable CK_RSA_PKCS_OAEP_PARAMS mOAEPParams = {}; }; @@ -168,6 +182,173 @@ class PKCS11ECDSAPrivateKey : public crypto::PrivateKeyItf { crypto::ECDSAPublicKey mPublicKey; }; +/** + * Converter for mechanism options of AES-GCM/AES-CTR decryption. + */ +struct PKCS11AESMechConverter : public StaticVisitor> { +public: + /** + * Constructs object instance. + * + * @param ctrCounterBits CK_AES_CTR_PARAMS::ulCounterBits value used for CTR mechanisms. + */ + explicit PKCS11AESMechConverter(CK_ULONG ctrCounterBits) + : mCTRCounterBits(ctrCounterBits) + { + } + + /** + * Rejects a PKCS1v15 option: not applicable to a symmetric key. + * + * @return RetWithError. + */ + RetWithError Visit(const crypto::PKCS1v15DecryptionOptions& options) const; + + /** + * Rejects an OAEP option: not applicable to a symmetric key. + * + * @return RetWithError. + */ + RetWithError Visit(const crypto::OAEPDecryptionOptions& options) const; + + /** + * Converts GCM decryption options to a CKM_AES_GCM mechanism. + * + * @param options GCM decrypt options (IV). + * @return RetWithError. + */ + RetWithError Visit(const crypto::GCMDecryptionOptions& options) const; + + /** + * Converts CTR decryption options to a CKM_AES_CTR mechanism. + * + * @param options CTR decrypt options (initial counter block). + * @return RetWithError. + */ + RetWithError Visit(const crypto::CTRDecryptionOptions& options) const; + +private: + CK_ULONG mCTRCounterBits; + mutable CK_GCM_PARAMS mGCMParams = {}; + mutable CK_AES_CTR_PARAMS mCTRParams = {}; +}; + +/** + * A PKCS11 CKO_SECRET_KEY (AES) object that decrypts without ever reading the key's own value: the raw key + * bytes never leave the token. Implements crypto::PrivateKeyItf so it can be loaded and handled the same way + * as an RSA/ECDSA private key (see pkcs11::Utils::FindPrivateKey); a symmetric key has no public part or + * signing capability, so GetPublic/Sign simply don't apply and Decrypt only accepts GCMDecryptionOptions or + * CTRDecryptionOptions. + */ +class AESPrivateKey : public crypto::PrivateKeyItf { +public: + /** + * Supported key size in bytes (CKA_VALUE_LEN): AES-256 only. + */ + static constexpr CK_ULONG cKeySize = 32; + + /** + * Constructs object instance. + * + * @param session session context. + * @param keyHandle secret key handle. + */ + AESPrivateKey(const SharedPtr& session, ObjectHandle keyHandle); + + /** + * A symmetric key has no public part. Returns a placeholder that must never be meaningfully used: nothing + * that knows this is a symmetric key has a reason to call this. + * + * @return const crypto::PublicKeyItf&. + */ + const crypto::PublicKeyItf& GetPublic() const override; + + /** + * Not supported: a symmetric key does not sign. + * + * @return Error always ErrorEnum::eNotSupported. + */ + Error Sign( + const Array& digest, const crypto::SignOptions& options, Array& signature) const override; + + /** + * Decrypts an AES-256-GCM or AES-256-CTR encrypted message using this key on the token. With + * GCMDecryptionOptions (the IV), cipher is the ciphertext followed by the 16-byte authentication tag, as + * produced by a typical AEAD API. With CTRDecryptionOptions (the initial counter block), cipher is the bare + * ciphertext and nothing is authenticated. Returns ErrorEnum::eNotSupported for any other + * DecryptionOptions kind. + * + * @param cipher encrypted message. + * @param options decryption options; must hold GCMDecryptionOptions or CTRDecryptionOptions. + * @param[out] result decoded message. + * @return Error. + */ + Error Decrypt( + const Array& cipher, const crypto::DecryptionOptions& options, Array& result) const override; + + /** + * Same as Decrypt, but reads the ciphertext incrementally from chunkProvider via a multi-part + * PKCS11 operation instead of requiring it all in memory up front, and hands decrypted data to + * chunkReceiver as the token releases it (see SessionContext::DecryptMultiPart). For GCM, + * chunkReceiver's buffer must still have capacity for the whole plaintext: most PKCS11 modules, + * including SoftHSM2 and OP-TEE, only release AEAD-decrypted data once the authentication tag has been + * verified, all at once, at the very end (OP-TEE also holds all of it in its TA heap until then). For + * CTR, each step releases as much plaintext as it was given ciphertext, so a buffer as large as the + * biggest input chunk is enough and token memory stays bounded regardless of the total size. + * + * @param chunkProvider supplies the cipher message in chunks. + * @param options decryption options; must hold GCMDecryptionOptions or CTRDecryptionOptions. + * @param chunkReceiver receives the decoded message in chunks. + * @return Error. ErrorEnum::eNotSupported if this token doesn't support multi-part decrypt operations + * for the requested mechanism at all: retry via Decrypt() instead. + */ + Error StreamDecrypt(crypto::ChunkProviderItf& chunkProvider, const crypto::DecryptionOptions& options, + crypto::ChunkReceiverItf& chunkReceiver) const override; + +private: + // CK_AES_CTR_PARAMS::ulCounterBits as PKCS#11 defines it: the whole 16-byte block is the counter. + static constexpr CK_ULONG cCTRCounterBits = crypto::AESCipherItf::cBlockSize * 8; + // OP-TEE's PKCS11 TA (at least up to 4.x) misreads ulCounterBits as an increment and rejects anything + // but 1 with CKR_MECHANISM_PARAM_INVALID, while still incrementing the whole 128-bit block - i.e. it + // behaves exactly like cCTRCounterBits. Only used once a token rejects cCTRCounterBits, and only after + // VerifyFallbackCounter has confirmed the token really behaves that way: a spec-compliant token would treat + // it as a 1-bit counter that wraps every other block. + static constexpr CK_ULONG cOPTEECTRCounterBits = 1; + + // Runs op(mechanism) with options converted to a PKCS11 mechanism, using the CTR counter width this token + // accepts (see GetCTRCounterBits). + template + Error WithMechanism(const crypto::DecryptionOptions& options, Op op) const; + + // Returns the CTR counter width to use with this token, determined on first use - before any caller data is + // consumed - by a single-block probe with cCTRCounterBits, falling back to cOPTEECTRCounterBits if the token + // rejects it and VerifyFallbackCounter passes. The result is cached. + Error GetCTRCounterBits(CK_ULONG& counterBits) const; + + // Known-answer check that, with cOPTEECTRCounterBits, the token carries the counter across the whole block + // rather than wrapping a 1-bit counter. + Error VerifyFallbackCounter() const; + + // Only exists to satisfy PrivateKeyItf::GetPublic's reference-returning signature for a key type that + // has no public part; GetKeyType/IsEqual are never meaningfully called on it. + class NoPublicKey : public crypto::PublicKeyItf { + public: + crypto::KeyType GetKeyType() const override { return crypto::KeyType {}; } + bool IsEqual(const crypto::PublicKeyItf& pubKey) const override + { + (void)pubKey; + + return false; + } + }; + + SharedPtr mSession; + ObjectHandle mKeyHandle; + NoPublicKey mNoPublicKey; + mutable Mutex mMutex; + mutable CK_ULONG mCTRCounterBits = 0; +}; + } // namespace aos::pkcs11 #endif diff --git a/src/core/common/pkcs11/tests/pkcs11.cpp b/src/core/common/pkcs11/tests/pkcs11.cpp index 17142bc2a..7cc1e8750 100644 --- a/src/core/common/pkcs11/tests/pkcs11.cpp +++ b/src/core/common/pkcs11/tests/pkcs11.cpp @@ -5,8 +5,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include #include +#include +#include #include #include @@ -20,6 +23,232 @@ using namespace testing; namespace aos::pkcs11 { +namespace { + +/*********************************************************************************************************************** + * Helpers + **********************************************************************************************************************/ + +using Bytes = std::vector; + +constexpr auto cBlockSize = crypto::AESCipherItf::cBlockSize; +constexpr auto cIVSize = crypto::AESCipherItf::cGCMIVSize; +constexpr auto cTagSize = crypto::AESCipherItf::cGCMTagSize; + +Bytes Pattern(size_t size, uint8_t seed) +{ + Bytes result(size); + + for (size_t i = 0; i < size; i++) { + result[i] = static_cast(seed + i * 31); + } + + return result; +} + +// Supplies data in fixed-size chunks, optionally failing on the given (1-based) chunk. +class VectorChunkProvider : public crypto::ChunkProviderItf { +public: + VectorChunkProvider(const Bytes& data, size_t chunkSize, size_t failOnChunk = 0) + : mData(data) + , mChunkSize(chunkSize) + , mFailOnChunk(failOnChunk) + { + } + + RetWithError> NextChunk() override + { + if (mOffset == mData.size()) { + return {Array(), ErrorEnum::eEOF}; + } + + if (++mChunks == mFailOnChunk) { + return {Array(), ErrorEnum::eFailed}; + } + + auto size = std::min(mChunkSize, mData.size() - mOffset); + auto data = Array(mData.data() + mOffset, size); + + mOffset += size; + + return {data, ErrorEnum::eNone}; + } + +private: + const Bytes& mData; + size_t mChunkSize; + size_t mFailOnChunk; + size_t mOffset = 0; + size_t mChunks = 0; +}; + +// Collects decrypted chunks, optionally failing on the given (1-based) chunk. +class VectorChunkReceiver : public crypto::ChunkReceiverItf { +public: + explicit VectorChunkReceiver(size_t bufferSize, size_t failOnChunk = 0) + : mStorage(bufferSize) + , mBuffer(mStorage.data(), bufferSize) + , mFailOnChunk(failOnChunk) + { + } + + Array& GetBuffer() override { return mBuffer; } + + Error OnChunk(const Array& chunk) override + { + if (++mChunks == mFailOnChunk) { + return ErrorEnum::eFailed; + } + + mResult.insert(mResult.end(), chunk.begin(), chunk.end()); + + return ErrorEnum::eNone; + } + + const Bytes& GetResult() const { return mResult; } + +private: + Bytes mStorage; + Array mBuffer; + size_t mFailOnChunk; + size_t mChunks = 0; + Bytes mResult; +}; + +// Emulates OP-TEE's PKCS11 TA on top of SoftHSM: CKM_AES_CTR is rejected at C_DecryptInit unless ulCounterBits is +// 1, which then behaves like a whole-block counter. With sCorruptCounterCheck, the second block of every two-block +// C_Decrypt is corrupted, as a spec-compliant token that honors a 1-bit counter would produce. +CK_FUNCTION_LIST_PTR sRealFunctions = nullptr; +bool sCorruptCounterCheck = false; +size_t sCounterChecks = 0; + +CK_RV OPTEEDecryptInit(CK_SESSION_HANDLE session, CK_MECHANISM_PTR mechanism, CK_OBJECT_HANDLE key) +{ + if (mechanism == nullptr || mechanism->mechanism != CKM_AES_CTR || mechanism->pParameter == nullptr) { + return sRealFunctions->C_DecryptInit(session, mechanism, key); + } + + auto params = *static_cast(mechanism->pParameter); + + if (params.ulCounterBits != 1) { + return CKR_MECHANISM_PARAM_INVALID; + } + + params.ulCounterBits = cBlockSize * 8; + + CK_MECHANISM realMechanism {CKM_AES_CTR, ¶ms, sizeof(params)}; + + return sRealFunctions->C_DecryptInit(session, &realMechanism, key); +} + +CK_RV OPTEEDecrypt( + CK_SESSION_HANDLE session, CK_BYTE_PTR encrypted, CK_ULONG encryptedLen, CK_BYTE_PTR data, CK_ULONG_PTR dataLen) +{ + auto rv = sRealFunctions->C_Decrypt(session, encrypted, encryptedLen, data, dataLen); + + if (rv == CKR_OK && data != nullptr && *dataLen == cBlockSize * 2) { + sCounterChecks++; + + if (sCorruptCounterCheck) { + data[cBlockSize] ^= 0x01; + } + } + + return rv; +} + +// Fault injection for object lookup: when set, C_FindObjectsInit/C_GetAttributeValue fail with the given CK_RV. +// With sTruncateAttributeValue, C_GetAttributeValue succeeds but reports a value shorter than the attribute's type. +CK_RV sFindObjectsInitRV = CKR_OK; +CK_RV sGetAttributeValueRV = CKR_OK; +bool sTruncateAttributeValue = false; + +CK_RV FaultyFindObjectsInit(CK_SESSION_HANDLE session, CK_ATTRIBUTE_PTR templ, CK_ULONG count) +{ + if (sFindObjectsInitRV != CKR_OK) { + return sFindObjectsInitRV; + } + + return sRealFunctions->C_FindObjectsInit(session, templ, count); +} + +CK_RV FaultyGetAttributeValue( + CK_SESSION_HANDLE session, CK_OBJECT_HANDLE object, CK_ATTRIBUTE_PTR templ, CK_ULONG count) +{ + if (sGetAttributeValueRV != CKR_OK) { + return sGetAttributeValueRV; + } + + auto rv = sRealFunctions->C_GetAttributeValue(session, object, templ, count); + + if (rv == CKR_OK && sTruncateAttributeValue && count != 0) { + templ[0].ulValueLen /= 2; + } + + return rv; +} + +// A token refusing CKM_AES_CTR with this key altogether. +CK_RV RefusingDecryptInit(CK_SESSION_HANDLE session, CK_MECHANISM_PTR mechanism, CK_OBJECT_HANDLE key) +{ + if (mechanism != nullptr && mechanism->mechanism == CKM_AES_CTR) { + return CKR_KEY_FUNCTION_NOT_PERMITTED; + } + + return sRealFunctions->C_DecryptInit(session, mechanism, key); +} + +// Fault injection on top of SoftHSM: when set, C_DecryptUpdate/C_DecryptFinal fail with the given CK_RV. +CK_RV sDecryptUpdateRV = CKR_OK; +CK_RV sDecryptFinalRV = CKR_OK; + +CK_RV FaultyDecryptUpdate( + CK_SESSION_HANDLE session, CK_BYTE_PTR encrypted, CK_ULONG encryptedLen, CK_BYTE_PTR data, CK_ULONG_PTR dataLen) +{ + if (sDecryptUpdateRV != CKR_OK) { + return sDecryptUpdateRV; + } + + return sRealFunctions->C_DecryptUpdate(session, encrypted, encryptedLen, data, dataLen); +} + +// Emulates PKCS#11 3.0 on top of SoftHSM (2.40): C_DecryptInit with a NULL mechanism terminates the active +// decryption operation. +// When set, C_DecryptInit with a mechanism fails with sDecryptInitRV. +size_t sDecryptCancels = 0; +CK_RV sDecryptInitRV = CKR_OK; + +CK_RV CancellingDecryptInit(CK_SESSION_HANDLE session, CK_MECHANISM_PTR mechanism, CK_OBJECT_HANDLE key) +{ + if (mechanism != nullptr) { + if (sDecryptInitRV != CKR_OK) { + return sDecryptInitRV; + } + + return sRealFunctions->C_DecryptInit(session, mechanism, key); + } + + sDecryptCancels++; + + Bytes discard(4096); + CK_ULONG size = discard.size(); + + (void)sRealFunctions->C_DecryptFinal(session, discard.data(), &size); + + return CKR_OK; +} + +CK_RV FaultyDecryptFinal(CK_SESSION_HANDLE session, CK_BYTE_PTR data, CK_ULONG_PTR dataLen) +{ + if (sDecryptFinalRV != CKR_OK) { + return sDecryptFinalRV; + } + + return sRealFunctions->C_DecryptFinal(session, data, dataLen); +} + +} // namespace + /*********************************************************************************************************************** * Suite **********************************************************************************************************************/ @@ -40,6 +269,116 @@ class PKCS11Test : public Test { mSlotID = mSoftHSMEnv.GetSlotID(); } + // Encrypts plain with AES-GCM in software under the key ImportSecretKey creates: ciphertext followed by the tag. + Bytes GCMEncrypt(const Bytes& key, const Bytes& iv, const Bytes& plain) + { + auto [cipher, err] = mCryptoProvider->CreateAESEncoder( + "GCM", Array(key.data(), key.size()), Array(iv.data(), iv.size())); + EXPECT_TRUE(err.IsNone()); + + if (!err.IsNone()) { + return {}; + } + + auto out = std::make_unique>(); + Bytes result; + + for (size_t offset = 0; offset < plain.size(); offset += out->MaxSize()) { + auto size = std::min(out->MaxSize(), plain.size() - offset); + + EXPECT_TRUE(cipher->EncryptBlock(Array(plain.data() + offset, size), *out).IsNone()); + result.insert(result.end(), out->begin(), out->end()); + } + + EXPECT_TRUE(cipher->Finalize(*out).IsNone()); + result.insert(result.end(), out->begin(), out->end()); + + StaticArray tag; + + EXPECT_TRUE(cipher->GetTag(tag).IsNone()); + result.insert(result.end(), tag.begin(), tag.end()); + + return result; + } + + // Value of the key ImportSecretKey creates. + static Bytes SecretKeyValue(size_t keySize) + { + Bytes value(keySize); + + for (size_t i = 0; i < keySize; i++) { + value[i] = static_cast(i + 1); + } + + return value; + } + + // CTR options for decrypting the payload of a GCM ciphertext: counter block IV || 0x00000002. + static crypto::DecryptionOptions PayloadCTROptions(const Bytes& iv) + { + crypto::CTRDecryptionOptions ctr; + + EXPECT_TRUE(ctr.mCounter.Assign(Array(iv.data(), iv.size())).IsNone()); + + for (auto byte : {0x00, 0x00, 0x00, 0x02}) { + EXPECT_TRUE(ctr.mCounter.PushBack(static_cast(byte)).IsNone()); + } + + return crypto::DecryptionOptions {ctr}; + } + + // Imports a 32-byte secret key and returns it, wrapped as AESPrivateKey, along with the GCM ciphertext (without + // tag) of plain under it. + void PrepareAESKey(const SharedPtr& session, const String& label, const Bytes& iv, + const Bytes& plain, PrivateKey& key, Bytes& cipher) + { + constexpr uint8_t cID[] = {0x01, 0x02, 0x03}; + const auto id = Array(cID, ArraySize(cID)); + + ASSERT_TRUE(ImportSecretKey(session, id, label, AESPrivateKey::cKeySize).mError.IsNone()); + + Error err; + + Tie(key, err) = Utils(mAllocator, session, *mCryptoProvider).FindPrivateKey(id, label); + ASSERT_TRUE(err.IsNone()); + + cipher = GCMEncrypt(SecretKeyValue(AESPrivateKey::cKeySize), iv, plain); + ASSERT_EQ(cipher.size(), plain.size() + cTagSize); + + cipher.resize(plain.size()); + } + + // Imports a non-extractable AES CKO_SECRET_KEY object of the given size, the way the layer key is provisioned. + RetWithError ImportSecretKey( + const SharedPtr& session, const Array& id, const String& label, size_t keySize) + { + CK_OBJECT_CLASS keyClass = CKO_SECRET_KEY; + CK_KEY_TYPE keyType = CKK_AES; + CK_BBOOL trueVal = CK_TRUE; + CK_BBOOL falseVal = CK_FALSE; + + auto value = SecretKeyValue(keySize); + + StaticArray templ; + + auto push = [&](AttributeType type, const void* data, size_t size) { + (void)templ.PushBack({type, Array(reinterpret_cast(const_cast(data)), size)}); + }; + + push(CKA_CLASS, &keyClass, sizeof(keyClass)); + push(CKA_KEY_TYPE, &keyType, sizeof(keyType)); + push(CKA_TOKEN, &trueVal, sizeof(trueVal)); + push(CKA_PRIVATE, &trueVal, sizeof(trueVal)); + push(CKA_EXTRACTABLE, &falseVal, sizeof(falseVal)); + push(CKA_SENSITIVE, &trueVal, sizeof(trueVal)); + push(CKA_DECRYPT, &trueVal, sizeof(trueVal)); + push(CKA_ID, id.Get(), id.Size()); + push(CKA_LABEL, label.Get(), label.Size()); + push(CKA_VALUE, value.data(), value.size()); + + return session->CreateObject(templ); + } + static constexpr auto mLabel = "iam pkcs11 test slot"; static constexpr auto mPIN = "admin"; @@ -263,6 +602,486 @@ TEST_F(PKCS11Test, FindPrivateKey) ASSERT_EQ(err, ErrorEnum::eNotFound); } +TEST_F(PKCS11Test, FindAndDeleteSecretKey) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + constexpr uint8_t cID[] = {0x0A, 0x0B, 0x0C}; + const auto id = Array(cID, ArraySize(cID)); + + ASSERT_TRUE(ImportSecretKey(session, id, "secret key", AESPrivateKey::cKeySize).mError.IsNone()); + + Utils utils(mAllocator, session, *mCryptoProvider); + + auto [key, findErr] = utils.FindPrivateKey(id, "secret key"); + ASSERT_TRUE(findErr.IsNone()); + EXPECT_EQ(key.GetPubHandle(), CK_INVALID_HANDLE); + + // a secret key has no public object: deleting it must not try to destroy one. + ASSERT_TRUE(utils.DeletePrivateKey(key).IsNone()); + + EXPECT_TRUE(utils.FindPrivateKey(id, "secret key").mError.Is(ErrorEnum::eNotFound)); +} + +TEST_F(PKCS11Test, FindSecretKeyRejectsNon256BitKey) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + constexpr uint8_t cID[] = {0x0D, 0x0E, 0x0F}; + const auto id = Array(cID, ArraySize(cID)); + + ASSERT_TRUE(ImportSecretKey(session, id, "aes128 key", 16).mError.IsNone()); + + EXPECT_TRUE(Utils(mAllocator, session, *mCryptoProvider) + .FindPrivateKey(id, "aes128 key") + .mError.Is(ErrorEnum::eInvalidArgument)); +} + +TEST_F(PKCS11Test, FindSecretKeyRejectsDuplicates) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + constexpr uint8_t cID[] = {0x0A, 0x0A, 0x0A}; + const auto id = Array(cID, ArraySize(cID)); + + ASSERT_TRUE(ImportSecretKey(session, id, "duplicate key", AESPrivateKey::cKeySize).mError.IsNone()); + ASSERT_TRUE(ImportSecretKey(session, id, "duplicate key", AESPrivateKey::cKeySize).mError.IsNone()); + + EXPECT_TRUE(Utils(mAllocator, session, *mCryptoProvider) + .FindPrivateKey(id, "duplicate key") + .mError.Is(ErrorEnum::eInvalidArgument)); +} + +TEST_F(PKCS11Test, FindSecretKeyReturnsTokenErrors) +{ + auto [userSession, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + constexpr uint8_t cID[] = {0x0B, 0x0B, 0x0B}; + const auto id = Array(cID, ArraySize(cID)); + + ASSERT_TRUE(ImportSecretKey(userSession, id, "lookup key", AESPrivateKey::cKeySize).mError.IsNone()); + + sRealFunctions = userSession->GetFunctionList(); + + CK_FUNCTION_LIST faultyFunctions = *sRealFunctions; + + faultyFunctions.C_FindObjectsInit = FaultyFindObjectsInit; + faultyFunctions.C_GetAttributeValue = FaultyGetAttributeValue; + + CK_SESSION_HANDLE handle = CK_INVALID_HANDLE; + + ASSERT_EQ( + sRealFunctions->C_OpenSession(mSlotID, CKF_SERIAL_SESSION | CKF_RW_SESSION, nullptr, nullptr, &handle), CKR_OK); + + auto session = MakeShared(&mAllocator, handle, &faultyFunctions); + ASSERT_TRUE(session); + + Utils utils(mAllocator, session, *mCryptoProvider); + + // the secret key search itself fails: that's returned, not mistaken for "no secret key, try a key pair". + sFindObjectsInitRV = CKR_DEVICE_ERROR; + + EXPECT_EQ(utils.FindPrivateKey(id, "lookup key").mError.Errno(), static_cast(CKR_DEVICE_ERROR)); + + // the key is found, but its size can't be read. + sFindObjectsInitRV = CKR_OK; + sGetAttributeValueRV = CKR_DEVICE_ERROR; + + EXPECT_EQ(utils.FindPrivateKey(id, "lookup key").mError.Errno(), static_cast(CKR_DEVICE_ERROR)); + + // the size is read, but isn't a CK_ULONG. + sGetAttributeValueRV = CKR_OK; + sTruncateAttributeValue = true; + + EXPECT_TRUE(utils.FindPrivateKey(id, "lookup key").mError.Is(ErrorEnum::eFailed)); + + sTruncateAttributeValue = false; + + EXPECT_TRUE(utils.FindPrivateKey(id, "lookup key").mError.IsNone()); +} + +TEST_F(PKCS11Test, AESMechConverterRejectsUnsupportedOptions) +{ + PKCS11AESMechConverter aesConverter(cBlockSize * 8); + + EXPECT_TRUE(crypto::DecryptionOptions {crypto::PKCS1v15DecryptionOptions {}} + .ApplyVisitor(aesConverter) + .mError.Is(ErrorEnum::eNotSupported)); + EXPECT_TRUE(crypto::DecryptionOptions {crypto::OAEPDecryptionOptions {}} + .ApplyVisitor(aesConverter) + .mError.Is(ErrorEnum::eNotSupported)); + + crypto::GCMDecryptionOptions shortIV; + + ASSERT_TRUE(shortIV.mIV.Resize(cIVSize - 1).IsNone()); + EXPECT_TRUE(crypto::DecryptionOptions {shortIV}.ApplyVisitor(aesConverter).mError.Is(ErrorEnum::eInvalidArgument)); + + crypto::CTRDecryptionOptions shortCounter; + + ASSERT_TRUE(shortCounter.mCounter.Resize(cBlockSize - 1).IsNone()); + EXPECT_TRUE( + crypto::DecryptionOptions {shortCounter}.ApplyVisitor(aesConverter).mError.Is(ErrorEnum::eInvalidArgument)); + + PCKS11RSAMechConverter rsaConverter; + + EXPECT_TRUE(crypto::DecryptionOptions {crypto::GCMDecryptionOptions {}} + .ApplyVisitor(rsaConverter) + .mError.Is(ErrorEnum::eNotSupported)); + EXPECT_TRUE(crypto::DecryptionOptions {crypto::CTRDecryptionOptions {}} + .ApplyVisitor(rsaConverter) + .mError.Is(ErrorEnum::eNotSupported)); +} + +TEST_F(PKCS11Test, AESPrivateKeyHasNoPublicPartAndDoesNotSign) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 1); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(session, "no public part", iv, Pattern(16, 2), key, cipher); + + const auto& privKey = *key.GetPrivKey(); + + EXPECT_EQ(privKey.GetPublic().GetKeyType(), crypto::KeyType {}); + EXPECT_FALSE(privKey.GetPublic().IsEqual(privKey.GetPublic())); + + StaticArray digest; + StaticArray signature; + + EXPECT_TRUE(privKey.Sign(digest, crypto::SignOptions {}, signature).Is(ErrorEnum::eNotSupported)); + + // RSA decryption options don't apply to it either, single-shot or streamed. + const auto rsaOptions = crypto::DecryptionOptions {crypto::PKCS1v15DecryptionOptions {}}; + + VectorChunkProvider provider(cipher, 16, 1); + VectorChunkReceiver receiver(16); + + EXPECT_TRUE(privKey.Decrypt(Array(cipher.data(), cipher.size()), rsaOptions, signature) + .Is(ErrorEnum::eNotSupported)); + EXPECT_TRUE(privKey.StreamDecrypt(provider, rsaOptions, receiver).Is(ErrorEnum::eNotSupported)); +} + +TEST_F(PKCS11Test, AESPrivateKeyDecryptsGCM) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 3); + const auto plain = Pattern(100, 4); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(session, "gcm key", iv, plain, key, cipher); + + auto sealed = GCMEncrypt(SecretKeyValue(AESPrivateKey::cKeySize), iv, plain); + + crypto::GCMDecryptionOptions gcm; + + ASSERT_TRUE(gcm.mIV.Assign(Array(iv.data(), iv.size())).IsNone()); + + StaticArray result; + + ASSERT_TRUE(key.GetPrivKey() + ->Decrypt(Array(sealed.data(), sealed.size()), crypto::DecryptionOptions {gcm}, result) + .IsNone()); + EXPECT_EQ(Bytes(result.begin(), result.end()), plain); + + // a modified tag fails authentication. + sealed.back() ^= 0x01; + + EXPECT_FALSE(key.GetPrivKey() + ->Decrypt(Array(sealed.data(), sealed.size()), crypto::DecryptionOptions {gcm}, result) + .IsNone()); +} + +TEST_F(PKCS11Test, AESPrivateKeyStreamDecryptsCTR) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 5); + const auto plain = Pattern(5000, 6); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(session, "ctr key", iv, plain, key, cipher); + + VectorChunkProvider provider(cipher, 1024); + VectorChunkReceiver receiver(1024); + + ASSERT_TRUE(key.GetPrivKey()->StreamDecrypt(provider, PayloadCTROptions(iv), receiver).IsNone()); + EXPECT_EQ(receiver.GetResult(), plain); +} + +TEST_F(PKCS11Test, AESPrivateKeyStreamDecryptAbortsOperationOnError) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 7); + const auto plain = Pattern(3000, 8); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(session, "abort key", iv, plain, key, cipher); + + const auto& privKey = *key.GetPrivKey(); + + // after every failure the operation must have been terminated: the next one on the same session succeeds. + auto decryptsAgain = [&]() { + VectorChunkProvider provider(cipher, 1024); + VectorChunkReceiver receiver(1024); + + EXPECT_TRUE(privKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).IsNone()); + EXPECT_EQ(receiver.GetResult(), plain); + }; + + { + VectorChunkProvider provider(cipher, 1024, 2); + VectorChunkReceiver receiver(1024); + + EXPECT_TRUE(privKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).Is(ErrorEnum::eFailed)); + } + + decryptsAgain(); + + { + VectorChunkProvider provider(cipher, 1024); + VectorChunkReceiver receiver(1024, 2); + + EXPECT_TRUE(privKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).Is(ErrorEnum::eFailed)); + } + + decryptsAgain(); + + { + // a token error other than "not supported" is returned as is. + VectorChunkProvider provider(cipher, 1024); + VectorChunkReceiver receiver(cBlockSize); + + auto streamErr = privKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver); + + EXPECT_EQ(streamErr.Errno(), static_cast(CKR_BUFFER_TOO_SMALL)); + } + + decryptsAgain(); +} + +TEST_F(PKCS11Test, StreamDecryptIsNotSupportedByRSAKey) +{ + auto [session, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + uuid::UUID id; + + Tie(id, err) = uuid::StringToUUID("08080808-0404-0404-0404-343434343434"); + ASSERT_TRUE(err.IsNone()); + + auto [key, keyErr] = Utils(mAllocator, session, *mCryptoProvider).GenerateRSAKeyPairWithLabel(id, mLabel, 2048); + ASSERT_TRUE(keyErr.IsNone()); + + const Bytes data(16); + VectorChunkProvider provider(data, 16); + VectorChunkReceiver receiver(16); + + EXPECT_TRUE( + key.GetPrivKey() + ->StreamDecrypt(provider, crypto::DecryptionOptions {crypto::PKCS1v15DecryptionOptions {}}, receiver) + .Is(ErrorEnum::eNotSupported)); +} + +TEST_F(PKCS11Test, AESPrivateKeyFallsBackToOPTEECounterBits) +{ + auto [userSession, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 9); + const auto plain = Pattern(2000, 10); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(userSession, "optee key", iv, plain, key, cipher); + + sRealFunctions = userSession->GetFunctionList(); + sCorruptCounterCheck = false; + sCounterChecks = 0; + + CK_FUNCTION_LIST opteeFunctions = *sRealFunctions; + + opteeFunctions.C_DecryptInit = OPTEEDecryptInit; + opteeFunctions.C_Decrypt = OPTEEDecrypt; + + CK_SESSION_HANDLE handle = CK_INVALID_HANDLE; + + ASSERT_EQ( + sRealFunctions->C_OpenSession(mSlotID, CKF_SERIAL_SESSION | CKF_RW_SESSION, nullptr, nullptr, &handle), CKR_OK); + + auto session = MakeShared(&mAllocator, handle, &opteeFunctions); + ASSERT_TRUE(session); + + AESPrivateKey opteeKey(session, key.GetPrivHandle()); + + for (int i = 0; i < 2; i++) { + VectorChunkProvider provider(cipher, 512); + VectorChunkReceiver receiver(512); + + ASSERT_TRUE(opteeKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).IsNone()); + EXPECT_EQ(receiver.GetResult(), plain); + } + + // a single-shot decrypt uses the same, cached, counter width. + StaticArray result; + + ASSERT_TRUE( + opteeKey.Decrypt(Array(cipher.data(), result.MaxSize()), PayloadCTROptions(iv), result).IsNone()); + EXPECT_EQ(Bytes(result.begin(), result.end()), Bytes(plain.begin(), plain.begin() + result.MaxSize())); + + // the whole-block counter check runs once and is cached. + EXPECT_EQ(sCounterChecks, 1U); + + // a token that really treats ulCounterBits = 1 as a 1-bit counter is rejected rather than decrypting garbage. + sCorruptCounterCheck = true; + + AESPrivateKey compliantKey(session, key.GetPrivHandle()); + VectorChunkProvider provider(cipher, 512); + VectorChunkReceiver receiver(512); + + EXPECT_TRUE(compliantKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).Is(ErrorEnum::eNotSupported)); + EXPECT_TRUE(receiver.GetResult().empty()); +} + +TEST_F(PKCS11Test, AESPrivateKeyReturnsCTRProbeErrorBeforeConsumingData) +{ + auto [userSession, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 13); + const auto plain = Pattern(500, 14); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(userSession, "refused key", iv, plain, key, cipher); + + sRealFunctions = userSession->GetFunctionList(); + + CK_FUNCTION_LIST refusingFunctions = *sRealFunctions; + + refusingFunctions.C_DecryptInit = RefusingDecryptInit; + + CK_SESSION_HANDLE handle = CK_INVALID_HANDLE; + + ASSERT_EQ( + sRealFunctions->C_OpenSession(mSlotID, CKF_SERIAL_SESSION | CKF_RW_SESSION, nullptr, nullptr, &handle), CKR_OK); + + auto session = MakeShared(&mAllocator, handle, &refusingFunctions); + ASSERT_TRUE(session); + + AESPrivateKey aesKey(session, key.GetPrivHandle()); + VectorChunkProvider provider(cipher, 256, 1); + VectorChunkReceiver receiver(256); + + // the counter width probe fails with an unrelated error: it is returned as is, with no fallback and before the + // provider is asked for anything (it would fail on its first chunk). + auto streamErr = aesKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver); + + EXPECT_EQ(streamErr.Errno(), static_cast(CKR_KEY_FUNCTION_NOT_PERMITTED)); + EXPECT_TRUE(receiver.GetResult().empty()); +} + +TEST_F(PKCS11Test, DecryptMultiPartReportsNotSupportedAndAbortsOperation) +{ + auto [userSession, err] = mSoftHSMEnv.OpenUserSession(mPIN, true); + ASSERT_TRUE(err.IsNone()); + + const auto iv = Pattern(cIVSize, 11); + const auto plain = Pattern(1000, 12); + PrivateKey key; + Bytes cipher; + + PrepareAESKey(userSession, "faulty key", iv, plain, key, cipher); + + sRealFunctions = userSession->GetFunctionList(); + + CK_FUNCTION_LIST faultyFunctions = *sRealFunctions; + + faultyFunctions.C_DecryptInit = CancellingDecryptInit; + faultyFunctions.C_DecryptUpdate = FaultyDecryptUpdate; + faultyFunctions.C_DecryptFinal = FaultyDecryptFinal; + + sDecryptCancels = 0; + + CK_SESSION_HANDLE handle = CK_INVALID_HANDLE; + + ASSERT_EQ( + sRealFunctions->C_OpenSession(mSlotID, CKF_SERIAL_SESSION | CKF_RW_SESSION, nullptr, nullptr, &handle), CKR_OK); + + auto session = MakeShared(&mAllocator, handle, &faultyFunctions); + ASSERT_TRUE(session); + + AESPrivateKey aesKey(session, key.GetPrivHandle()); + + // a token that can't do multi-part decryption for the mechanism is reported as such; the abort, where + // C_DecryptFinal keeps failing with CKR_BUFFER_TOO_SMALL, falls back to C_DecryptInit with no mechanism. + sDecryptUpdateRV = CKR_FUNCTION_NOT_SUPPORTED; + sDecryptFinalRV = CKR_BUFFER_TOO_SMALL; + + { + VectorChunkProvider provider(cipher, 256); + VectorChunkReceiver receiver(256); + + EXPECT_TRUE(aesKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).Is(ErrorEnum::eNotSupported)); + } + + EXPECT_EQ(sDecryptCancels, 1U); + + sDecryptUpdateRV = CKR_OK; + sDecryptFinalRV = CKR_OK; + + { + VectorChunkProvider provider(cipher, 256); + VectorChunkReceiver receiver(256); + + EXPECT_TRUE(aesKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).IsNone()); + EXPECT_EQ(receiver.GetResult(), plain); + } + + // the operation can't even be started: nothing is read from the provider. + sDecryptInitRV = CKR_DEVICE_ERROR; + + { + VectorChunkProvider provider(cipher, 256, 1); + VectorChunkReceiver receiver(256); + + EXPECT_EQ(aesKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).Errno(), + static_cast(CKR_DEVICE_ERROR)); + } + + sDecryptInitRV = CKR_OK; + + // a C_DecryptFinal failure other than "not supported" is returned as is. + sDecryptFinalRV = CKR_DEVICE_ERROR; + + { + VectorChunkProvider provider(cipher, 256); + VectorChunkReceiver receiver(256); + + EXPECT_EQ(aesKey.StreamDecrypt(provider, PayloadCTROptions(iv), receiver).Errno(), + static_cast(CKR_DEVICE_ERROR)); + } + + sDecryptFinalRV = CKR_OK; + + // the faked failure left SoftHSM's operation active: terminate it before the session is closed. + EXPECT_EQ(faultyFunctions.C_DecryptInit(handle, nullptr, CK_INVALID_HANDLE), CKR_OK); +} + TEST_F(PKCS11Test, ImportCertificate) { Error err; diff --git a/src/core/common/tests/mocks/cryptomock.hpp b/src/core/common/tests/mocks/cryptomock.hpp index de7f6171a..6ce4d31e0 100644 --- a/src/core/common/tests/mocks/cryptomock.hpp +++ b/src/core/common/tests/mocks/cryptomock.hpp @@ -12,6 +12,7 @@ #include #include +#include namespace aos::crypto { @@ -30,6 +31,22 @@ class CryptoHelperMock : public CryptoHelperItf { MOCK_METHOD(Error, DecryptMetadata, (const Array& input, Array& output), (override)); }; +/** + * Provides interface to mock a private (or symmetric, e.g. pkcs11::AESPrivateKey) key that never exposes + * its own value. + */ +class PrivateKeyMock : public PrivateKeyItf { +public: + MOCK_METHOD(const PublicKeyItf&, GetPublic, (), (const, override)); + MOCK_METHOD(Error, Sign, (const Array& digest, const SignOptions& options, Array& signature), + (const, override)); + MOCK_METHOD(Error, Decrypt, + (const Array& cipher, const DecryptionOptions& options, Array& result), (const, override)); + MOCK_METHOD(Error, StreamDecrypt, + (ChunkProviderItf & chunkProvider, const DecryptionOptions& options, ChunkReceiverItf& chunkReceiver), + (const, override)); +}; + namespace x509 { /** diff --git a/src/core/iam/certhandler/certmodule.cpp b/src/core/iam/certhandler/certmodule.cpp index 179b2fea2..33d41e1fe 100644 --- a/src/core/iam/certhandler/certmodule.cpp +++ b/src/core/iam/certhandler/certmodule.cpp @@ -56,6 +56,19 @@ Error CertModule::GetCertificate(const Array& issuer, const ArrayGetCertsInfo(GetCertType(), *certsInStorage); !err.IsNone()) { return AOS_ERROR_WRAP(err); diff --git a/src/core/sm/imagemanager/CMakeLists.txt b/src/core/sm/imagemanager/CMakeLists.txt index f9e22b824..2dd9eff1e 100644 --- a/src/core/sm/imagemanager/CMakeLists.txt +++ b/src/core/sm/imagemanager/CMakeLists.txt @@ -14,13 +14,21 @@ set(TARGET_NAME imagemanager) # Sources # ###################################################################################################################### -set(SOURCES imagemanager.cpp) +set(SOURCES blobdecryptor.cpp imagemanager.cpp) # ###################################################################################################################### # Headers # ###################################################################################################################### -set(HEADERS itf/blobinfoprovider.hpp itf/imagemanager.hpp itf/iteminfoprovider.hpp config.hpp imagemanager.hpp) +set(HEADERS + itf/blobdecryptor.hpp + itf/blobinfoprovider.hpp + itf/imagemanager.hpp + itf/iteminfoprovider.hpp + blobdecryptor.hpp + config.hpp + imagemanager.hpp +) # ###################################################################################################################### # Libraries diff --git a/src/core/sm/imagemanager/README.md b/src/core/sm/imagemanager/README.md new file mode 100644 index 000000000..4ca5fda23 --- /dev/null +++ b/src/core/sm/imagemanager/README.md @@ -0,0 +1,225 @@ +# Image Manager + +## Encrypted layer blobs + +A layer can be published as an encrypted blob (`mediaType: +application/vnd.aos.image.layer.enc.v1.aes256gcm+tar+gz`) so that its content is unreadable to anything +that only sees the manifest and the blob itself. Decryption happens locally on the node, using a +symmetric key that is never transmitted over the network. + +### Design + +- Each node holds a single AES-256 key, stored as a PKCS11 secret key object (`CKO_SECRET_KEY`/`CKK_AES`) + on a token IAM's cert module system also manages. Unlike other keys IAM loads, this cert module (see + `certType` below) is **dedicated** to the layer key: its registered key URL's id/label directly identify + the `CKO_SECRET_KEY` object (see `aos::sm::imagemanager::BlobDecryptor`), which is why there is no separate + layer-key-specific label constant anywhere in this code — provisioning just needs to import the key under + whatever id/label that cert module is configured with in IAM. +- The key is provisioned **non-extractable** (`CKA_EXTRACTABLE=CK_FALSE`, `CKA_SENSITIVE=CK_TRUE`) and is + never read off the token: all decryption happens through PKCS11 operations on the token itself + (`aos::pkcs11::AESPrivateKey`, which implements `aos::crypto::PrivateKeyItf` the same way + `PKCS11RSAPrivateKey`/`PKCS11ECDSAPrivateKey` do — `GetPublic`/`Sign` simply aren't supported for it; only + `Decrypt`/`StreamDecrypt` with `crypto::GCMDecryptionOptions` or `crypto::CTRDecryptionOptions` are, via a + single-shot `C_Decrypt` or a + multi-part `C_DecryptUpdate`+`C_DecryptFinal` operation respectively — see "On-device decryption" below). + The raw key bytes exist only at provisioning time, on the machine that generates them — the running node + process never holds them, and loading this key goes through the very same `CertLoaderItf::LoadPrivKeyByURL` + any other private key does. +- The key is looked up lazily, the first time an encrypted layer is actually installed — a node with no + key provisioned starts up normally and only fails when asked to install an encrypted layer. +- The algorithm is AES-256-GCM: authenticated encryption, so a wrong key or a modified blob is detected + and rejected instead of decrypting to garbage. GCM is a stream mode, so no padding is involved (PKCS7 + only applies to CBC). +- The node doesn't use `CKM_AES_GCM` for it, though: OP-TEE's PKCS11 TA keeps the whole GCM plaintext in + its TA heap until `C_DecryptFinal` has verified the tag, so any blob larger than that heap (256 KiB on + RPi) fails with `CKR_DEVICE_MEMORY`. Instead, the payload is decrypted on the token with + **`CKM_AES_CTR`**, which streams with constant token memory — GCM encrypts its payload with plain CTR + starting from counter block `IV || 00 00 00 02`, so CTR yields the same plaintext — and the tag is + verified in software as `tag = AES_K(J0) ⊕ GHASH_H(ciphertext)`, with `J0 = IV || 00 00 00 01`. The two + key-dependent values, `H = AES_K(0¹²⁸)` and `AES_K(J0)`, are CTR keystream blocks obtained from the token + by CTR-decrypting a zero block, so the key needs nothing beyond `CKA_DECRYPT`. They don't reveal the key, + but would allow forging a tag for that IV; they only live in the node process's memory during one + decryption, where an attacker able to read them could equally tamper with the output itself. +- The IV (nonce) is not derived or coordinated separately: it travels with the data as the first 12 bytes + of the encrypted blob (see `aos::sm::imagemanager::BlobDecryptor`). Producing an encrypted blob therefore + needs no synchronization with the device beyond knowing the AES key. Use a fresh random IV for every + blob: with GCM, reusing an IV with the same key is a serious weakness, not just bad practice. + +### File format + +```text +[ 12 bytes IV ][ AES-256-GCM ciphertext of the gzip'd layer tar ][ 16 bytes authentication tag ] +``` + +The ciphertext is exactly as long as the plaintext (no padding). This is an IV followed by the output of a +typical AEAD API such as Python's `AESGCM(key).encrypt(iv, data, None)`, which returns the ciphertext with the +tag already appended. No additional authenticated data is used. + +### One-time device provisioning + +`--token-label` below must be the token that the dedicated "layer key" IAM cert module is actually +configured with (`certModules[].params.tokenLabel`); if that param is left empty, IAM's PKCS11 module +falls back to its own default label (`aos`), not `aoscore` — check the node's config rather than assuming +either. `--id`/`--label` must match that same cert module's configured key id/label exactly: unlike a real +cert module's own keypair, this cert module exists purely so its registered key URL's id/label point at +this `CKO_SECRET_KEY` object — get both values from that config, not from this document. +`--private` marks the object `CKA_PRIVATE`, so it is only readable after PIN login, the same as the +cert module's own private key. + +```bash +# on the build machine +openssl rand -out layerkey.bin 32 + +# copy layerkey.bin to the device, then on the device (replace //