From 65f87a91eeea5ed376dd5c55a7583523648f0a21 Mon Sep 17 00:00:00 2001 From: Mykhailo Lohvynenko Date: Fri, 25 Sep 2026 17:07:29 +0300 Subject: [PATCH 01/11] WIP: hardcode itemencryption key URL. Signed-off-by: Mykhailo Lohvynenko --- src/core/iam/certhandler/certmodule.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/core/iam/certhandler/certmodule.cpp b/src/core/iam/certhandler/certmodule.cpp index 179b2fea2..13f01304a 100644 --- a/src/core/iam/certhandler/certmodule.cpp +++ b/src/core/iam/certhandler/certmodule.cpp @@ -56,6 +56,15 @@ Error CertModule::GetCertificate(const Array& issuer, const ArrayGetCertsInfo(GetCertType(), *certsInStorage); !err.IsNone()) { return AOS_ERROR_WRAP(err); From 2af25ab839e2fc02790b7254b30324c53b6eef57 Mon Sep 17 00:00:00 2001 From: Mykhailo Lohvynenko Date: Fri, 25 Sep 2026 17:11:21 +0300 Subject: [PATCH 02/11] common: ocispec: add media type constant for encrypted tar gz layers Signed-off-by: Mykhailo Lohvynenko --- src/core/common/ocispec/itf/imagespec.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/common/ocispec/itf/imagespec.hpp b/src/core/common/ocispec/itf/imagespec.hpp index 536652cf7..7623212ea 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+gzip"; 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"; From a5a478286fb8bf0080dfc38d7342282fbea8feb8 Mon Sep 17 00:00:00 2001 From: Mykhailo Lohvynenko Date: Fri, 25 Sep 2026 18:40:26 +0300 Subject: [PATCH 03/11] WIP: use gz media type - some issue on cloud side To be removed once cloud side issue is resolved. Signed-off-by: Mykhailo Lohvynenko --- src/core/common/ocispec/itf/imagespec.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/common/ocispec/itf/imagespec.hpp b/src/core/common/ocispec/itf/imagespec.hpp index 7623212ea..c907e5b1f 100644 --- a/src/core/common/ocispec/itf/imagespec.hpp +++ b/src/core/common/ocispec/itf/imagespec.hpp @@ -57,7 +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+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"; From 0f3b8ab5a1d27252b085c9dbf12ec9d96aa5933b Mon Sep 17 00:00:00 2001 From: Mykhailo Lohvynenko Date: Fri, 25 Sep 2026 17:25:43 +0300 Subject: [PATCH 04/11] common: pkcs11: implement AES private key Signed-off-by: Mykhailo Lohvynenko --- src/core/common/crypto/itf/privkey.hpp | 65 ++++++++- .../common/crypto/mbedtls/cryptoprovider.cpp | 7 + .../common/crypto/openssl/cryptoprovider.cpp | 7 + src/core/common/crypto/tests/certloader.cpp | 83 +++++++++++ src/core/common/pkcs11/pkcs11.cpp | 136 ++++++++++++++++++ src/core/common/pkcs11/pkcs11.hpp | 28 +++- src/core/common/pkcs11/privatekey.cpp | 91 ++++++++++++ src/core/common/pkcs11/privatekey.hpp | 118 +++++++++++++++ src/core/common/tests/mocks/cryptomock.hpp | 17 +++ 9 files changed, 550 insertions(+), 2 deletions(-) diff --git a/src/core/common/crypto/itf/privkey.hpp b/src/core/common/crypto/itf/privkey.hpp index e2945c9a8..6e721e203 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,46 @@ struct OAEPDecryptionOptions { Hash mHash; }; +/** + * AES-GCM decryption options. + */ +struct GCMDecryptionOptions { + /** + * GCM initialization vector (nonce): AESCipherItf::cGCMIVSize (12) bytes. + */ + StaticArray mIV; +}; + /** * 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; +}; /** * Public key interface. @@ -141,6 +179,31 @@ 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. 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 result must still have capacity for the whole plaintext regardless of how + * the input was chunked (see pkcs11::AESPrivateKey for the concrete behavior). 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[out] result decoded message. + * @return Error. + */ + virtual Error StreamDecrypt( + ChunkProviderItf& chunkProvider, const DecryptionOptions& options, Array& result) const + { + (void)chunkProvider; + (void)options; + (void)result; + + 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..c7bf17143 100644 --- a/src/core/common/crypto/mbedtls/cryptoprovider.cpp +++ b/src/core/common/crypto/mbedtls/cryptoprovider.cpp @@ -1874,6 +1874,13 @@ Error MbedTLSCryptoProvider::MbedTLSRSAPrivKey::Decrypt( return ErrorEnum::eNone; } + Error Visit(const GCMDecryptionOptions& 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..a7a5508fc 100644 --- a/src/core/common/crypto/openssl/cryptoprovider.cpp +++ b/src/core/common/crypto/openssl/cryptoprovider.cpp @@ -2790,6 +2790,13 @@ Error OpenSSLCryptoProvider::OpenSSLRSAPrivKey::Decrypt( return ErrorEnum::eNone; } + Error Visit(const GCMDecryptionOptions& 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..2918a8d95 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}; + + 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}; + + 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/pkcs11/pkcs11.cpp b/src/core/common/pkcs11/pkcs11.cpp index 4c897b5c2..4500a2795 100644 --- a/src/core/common/pkcs11/pkcs11.cpp +++ b/src/core/common/pkcs11/pkcs11.cpp @@ -760,6 +760,71 @@ Error SessionContext::Decrypt( return result.Resize(resultSize); } +Error SessionContext::DecryptMultiPart(CK_MECHANISM_PTR mechanism, ObjectHandle privKey, + crypto::ChunkProviderItf& chunkProvider, Array& result) const +{ + LockGuard lock {mMutex}; + + if (auto err = DecryptInit(mechanism, privKey); !err.IsNone()) { + return err; + } + + // Grow to full capacity up front: Array::Resize zero-fills newly exposed elements when growing, which + // would clobber the raw C_DecryptUpdate/C_DecryptFinal writes below if they landed before a later + // resize-up. Writing into an already-full-size buffer and only ever shrinking afterward (which doesn't + // zero anything) avoids that, matching the single-shot Decrypt() above. + if (auto err = result.Resize(result.MaxSize()); !err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + size_t written = 0; + bool anyChunkDecrypted = false; + + while (true) { + auto [chunk, err] = chunkProvider.NextChunk(); + if (err.Is(ErrorEnum::eEOF)) { + break; + } + + if (!err.IsNone()) { + return err; + } + + CK_ULONG outSize = result.MaxSize() - written; + + err = DecryptUpdate(chunk, result.Get() + written, &outSize); + if (!err.IsNone()) { + // Some PKCS11 modules don't support multi-part operations for AEAD mechanisms like + // CKM_AES_GCM at all: they only fail once actual data is pushed through + // C_DecryptUpdate, not at C_DecryptInit. Nothing has been consumed from chunkProvider + // except this one chunk, so if it's the very first, the caller can safely retry via a + // fresh, single-shot decrypt instead. + if (!anyChunkDecrypted) { + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); + } + + return err; + } + + anyChunkDecrypted = true; + written += outSize; + } + + CK_ULONG finalSize = result.MaxSize() - written; + + if (auto err = DecryptFinal(result.Get() + written, &finalSize); !err.IsNone()) { + if (!anyChunkDecrypted) { + return AOS_ERROR_WRAP(ErrorEnum::eNotSupported); + } + + return err; + } + + written += finalSize; + + return result.Resize(written); +} + SessionHandle SessionContext::GetHandle() const { LockGuard lock {mMutex}; @@ -844,6 +909,35 @@ Error SessionContext::Decrypt(const Array& data, CK_BYTE_PTR result, CK return ErrorEnum::eNone; } +Error SessionContext::DecryptUpdate(const Array& data, CK_BYTE_PTR result, CK_ULONG_PTR resultSize) const +{ + if (!mFunctionList || !mFunctionList->C_DecryptUpdate) { + return ErrorEnum::eWrongState; + } + + if (CK_RV rv + = mFunctionList->C_DecryptUpdate(mHandle, const_cast(data.Get()), data.Size(), result, resultSize); + rv != CKR_OK) { + 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; + } + + CK_RV rv = mFunctionList->C_DecryptFinal(mHandle, result, resultSize); + if (rv != CKR_OK) { + return static_cast(rv); + } + + return ErrorEnum::eNone; +} + Error SessionContext::FindObjectsInit(const Array& templ) const { if (!mFunctionList || !mFunctionList->C_FindObjectsInit) { @@ -1169,6 +1263,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; @@ -1426,6 +1552,16 @@ RetWithError Utils::ExportPrivateKey( ObjectHandle privKeyHandle, ObjectHandle pubKeyHandle, CK_KEY_TYPE keyType) { switch (keyType) { + case CKK_AES: { + // 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..b1f55face 100644 --- a/src/core/common/pkcs11/pkcs11.hpp +++ b/src/core/common/pkcs11/pkcs11.hpp @@ -446,6 +446,27 @@ 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 appended to result 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 result + * 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[out] result decrypted data. + * @return Error. If the very first C_DecryptUpdate call fails (nothing decrypted yet), this + * returns ErrorEnum::eNotSupported: some PKCS11 modules don't support multi-part operations for + * AEAD mechanisms at all, and only fail once actual data is pushed through, not at + * C_DecryptInit. The caller can safely retry via a fresh, single-shot Decrypt() instead. A + * failure after that point is a real error (e.g. a bad tag), not a capability gap. + */ + Error DecryptMultiPart(CK_MECHANISM_PTR mechanism, ObjectHandle privKey, crypto::ChunkProviderItf& chunkProvider, + Array& result) const; + /** * Returns session handle. * @@ -471,6 +492,8 @@ 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 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 +774,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..ce6d7f254 100644 --- a/src/core/common/pkcs11/privatekey.cpp +++ b/src/core/common/pkcs11/privatekey.cpp @@ -151,6 +151,13 @@ RetWithError PCKS11RSAMechConverter::Visit(const crypto::OAEPDecry return mech; } +RetWithError PCKS11RSAMechConverter::Visit(const crypto::GCMDecryptionOptions& options) const +{ + (void)options; + + return {{}, AOS_ERROR_WRAP(ErrorEnum::eNotSupported)}; +} + /*********************************************************************************************************************** * PKCS11ECDSAPrivateKey **********************************************************************************************************************/ @@ -181,4 +188,88 @@ 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()); + 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)}; +} + +/*********************************************************************************************************************** + * 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 +{ + PKCS11AESMechConverter visitor; + + auto [mech, err] = options.ApplyVisitor(visitor); + if (!err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + return mSession->Decrypt(&mech, mKeyHandle, cipher, result); +} + +Error AESPrivateKey::StreamDecrypt( + crypto::ChunkProviderItf& chunkProvider, const crypto::DecryptionOptions& options, Array& result) const +{ + PKCS11AESMechConverter visitor; + + auto [mech, err] = options.ApplyVisitor(visitor); + if (!err.IsNone()) { + return AOS_ERROR_WRAP(err); + } + + return mSession->DecryptMultiPart(&mech, mKeyHandle, chunkProvider, result); +} + } // namespace aos::pkcs11 diff --git a/src/core/common/pkcs11/privatekey.hpp b/src/core/common/pkcs11/privatekey.hpp index 37286383e..512f643a8 100644 --- a/src/core/common/pkcs11/privatekey.hpp +++ b/src/core/common/pkcs11/privatekey.hpp @@ -104,6 +104,13 @@ 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; + private: mutable CK_RSA_PKCS_OAEP_PARAMS mOAEPParams = {}; }; @@ -168,6 +175,117 @@ class PKCS11ECDSAPrivateKey : public crypto::PrivateKeyItf { crypto::ECDSAPublicKey mPublicKey; }; +/** + * Converter for mechanism options of AES-GCM decryption. + */ +struct PKCS11AESMechConverter : public StaticVisitor> { +public: + /** + * 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; + +private: + mutable CK_GCM_PARAMS mGCMParams = {}; +}; + +/** + * 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. + */ +class AESPrivateKey : public crypto::PrivateKeyItf { +public: + /** + * 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 encrypted message using this key on the token. options must hold + * GCMDecryptionOptions (the IV); cipher is the ciphertext followed by the 16-byte authentication tag, as + * produced by a typical AEAD API. Returns ErrorEnum::eNotSupported for any other DecryptionOptions kind. + * + * @param cipher ciphertext followed by the authentication tag. + * @param options decryption options; must hold GCMDecryptionOptions. + * @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 (see + * SessionContext::DecryptMultiPart). result must still have capacity for the whole plaintext: + * most PKCS11 modules, including SoftHSM2, only release AEAD-decrypted data once the + * authentication tag has been verified, all at once, at the very end. + * + * @param chunkProvider supplies the cipher message in chunks. + * @param options decryption options; must hold GCMDecryptionOptions. + * @param[out] result decoded message. + * @return Error. ErrorEnum::eNotSupported if this token doesn't support multi-part CKM_AES_GCM + * decrypt operations at all: retry via Decrypt() instead. + */ + Error StreamDecrypt(crypto::ChunkProviderItf& chunkProvider, const crypto::DecryptionOptions& options, + Array& result) const override; + +private: + // 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; +}; + } // namespace aos::pkcs11 #endif diff --git a/src/core/common/tests/mocks/cryptomock.hpp b/src/core/common/tests/mocks/cryptomock.hpp index de7f6171a..50e5136fb 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, Array& result), + (const, override)); +}; + namespace x509 { /** From f1fc1c6c05c57bb676a1696418061967503c8a87 Mon Sep 17 00:00:00 2001 From: Mykhailo Lohvynenko Date: Fri, 25 Sep 2026 17:29:23 +0300 Subject: [PATCH 05/11] WIP: works Signed-off-by: Mykhailo Lohvynenko --- src/core/common/config.hpp | 12 + src/core/sm/imagemanager/CMakeLists.txt | 12 +- src/core/sm/imagemanager/README.md | 204 ++++++ src/core/sm/imagemanager/blobdecryptor.cpp | 226 +++++++ src/core/sm/imagemanager/blobdecryptor.hpp | 94 +++ src/core/sm/imagemanager/imagemanager.cpp | 45 +- src/core/sm/imagemanager/imagemanager.hpp | 6 +- .../sm/imagemanager/itf/blobdecryptor.hpp | 43 ++ src/core/sm/imagemanager/tests/CMakeLists.txt | 6 +- .../sm/imagemanager/tests/blobdecryptor.cpp | 605 ++++++++++++++++++ .../sm/imagemanager/tests/imagemanager.cpp | 150 ++++- .../tests/mocks/blobdecryptormock.hpp | 26 + 12 files changed, 1421 insertions(+), 8 deletions(-) create mode 100644 src/core/sm/imagemanager/README.md create mode 100644 src/core/sm/imagemanager/blobdecryptor.cpp create mode 100644 src/core/sm/imagemanager/blobdecryptor.hpp create mode 100644 src/core/sm/imagemanager/itf/blobdecryptor.hpp create mode 100644 src/core/sm/imagemanager/tests/blobdecryptor.cpp create mode 100644 src/core/sm/imagemanager/tests/mocks/blobdecryptormock.hpp 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/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..6c679ea96 --- /dev/null +++ b/src/core/sm/imagemanager/README.md @@ -0,0 +1,204 @@ +# 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 AES-GCM 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` 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 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 //