diff --git a/apisix/cli/schema.lua b/apisix/cli/schema.lua index 3e3e855106c8..25c4cc507685 100644 --- a/apisix/cli/schema.lua +++ b/apisix/cli/schema.lua @@ -67,6 +67,18 @@ local etcd_schema = { required = {"prefix", "host"} } +-- a keyring entry is used both as the AES key and as its IV, so only the two +-- key lengths AES-CBC accepts here are allowed: 16 bytes (AES-128) and 32 +-- bytes (AES-256). Any other length would be dropped by +-- core.data_encryption.init_iv_tbl and silently leave the data unencrypted. +local keyring_key_schema = { + type = "string", + anyOf = { + {minLength = 16, maxLength = 16}, + {minLength = 32, maxLength = 32}, + } +} + local config_schema = { type = "object", properties = { @@ -246,17 +258,9 @@ local config_schema = { { type = "array", minItems = 1, - items = { - type = "string", - minLength = 16, - maxLength = 16 - } + items = keyring_key_schema }, - { - type = "string", - minLength = 16, - maxLength = 16 - } + keyring_key_schema } }, } diff --git a/apisix/core/data_encryption.lua b/apisix/core/data_encryption.lua index 88bbaf107448..f2394272b6ad 100644 --- a/apisix/core/data_encryption.lua +++ b/apisix/core/data_encryption.lua @@ -42,8 +42,11 @@ void ERR_clear_error(void); local _M = {} ---- Build a table of AES-128-CBC ciphers from a keyring, each using the key --- itself as the IV. +--- Build a table of AES-CBC ciphers from a keyring, each using the key itself +-- as the IV: a 16-byte key selects AES-128, a 32-byte key selects AES-256. +-- The CLI schema rejects any other length at startup; a key that still reaches +-- us with an unsupported length is dropped, so log it loudly -- an empty +-- keyring makes `_M.encrypt` fall back to storing the value in clear text. function _M.init_iv_tbl(ivs) local iv_tbl = tbl.new(2, 0) if type(ivs) == "string" then @@ -52,7 +55,14 @@ function _M.init_iv_tbl(ivs) if type(ivs) == "table" then for _, iv in ipairs(ivs) do - tbl.insert(iv_tbl, assert(aes:new(iv, nil, aes.cipher(128, "cbc"), {iv = iv}))) + if #iv == 32 then + tbl.insert(iv_tbl, assert(aes:new(iv, nil, aes.cipher(256, "cbc"), {iv = iv}))) + elseif #iv == 16 then + tbl.insert(iv_tbl, assert(aes:new(iv, nil, aes.cipher(128, "cbc"), {iv = iv}))) + else + log.error("ignored data_encryption keyring entry: expected a 16 byte ", + "(AES-128) or 32 byte (AES-256) key, got ", #iv, " bytes") + end end end diff --git a/conf/config.yaml.example b/conf/config.yaml.example index d6714427ad4f..e4bdeb645e1c 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -144,8 +144,8 @@ apisix: enable_encrypt_fields: true # Whether enable encrypt fields specified in `encrypt_fields` in plugin schema. keyring: # This field is used to encrypt the private key of SSL and the `encrypt_fields` # in plugin schema. - - qeddd145sfvddff3 # Set the encryption key for AES-128-CBC. It should be a hexadecimal string - # of length 16. + - qeddd145sfvddff3 # Set the encryption key. A key of length 16 selects AES-128-CBC and a key + # of length 32 selects AES-256-CBC; any other length is rejected on startup. - edd1c9f0985e76a2 # If not set, APISIX saves the original data into etcd. # CAUTION: If you would like to update the key, add the new key as the # first item in the array and keep the older keys below the newly added diff --git a/docs/en/latest/plugin-develop.md b/docs/en/latest/plugin-develop.md index deadabe81dac..cdf837cc7523 100644 --- a/docs/en/latest/plugin-develop.md +++ b/docs/en/latest/plugin-develop.md @@ -356,6 +356,8 @@ apisix: - ... ``` +Each key doubles as the IV, so its length selects the cipher: a key of 16 characters uses AES-128-CBC and a key of 32 characters uses AES-256-CBC. Keys of any other length are rejected when APISIX starts. Keys of both lengths can be mixed in one keyring, which is what makes it possible to rotate an AES-128 keyring to AES-256 without losing access to the already encrypted data. + APISIX will try to decrypt the data with keys in the order of the keys in the keyring (only for parameters declared in `encrypt_fields`). If the decryption fails, the next key will be tried until the decryption succeeds. If none of the keys in `keyring` can decrypt the data, the original data is used. diff --git a/docs/zh/latest/plugin-develop.md b/docs/zh/latest/plugin-develop.md index 907184372b16..8fa2c0d4e0e8 100644 --- a/docs/zh/latest/plugin-develop.md +++ b/docs/zh/latest/plugin-develop.md @@ -342,6 +342,8 @@ apisix: - ... ``` +每个 key 同时用作 IV,因此 key 的长度决定了使用的算法:长度为 16 的 key 使用 AES-128-CBC,长度为 32 的 key 使用 AES-256-CBC,其它长度的 key 会在 APISIX 启动时被拒绝。同一个 keyring 中可以混用这两种长度的 key,这样才能在不丢失已加密数据的前提下,把 AES-128 的 keyring 轮转为 AES-256。 + `keyring` 是一个数组,可以指定多个 key,APISIX 会按照 keyring 中 key 的顺序,依次尝试用 key 来解密数据(只对在 `encrypt_fields` 声明的参数)。如果解密失败,会尝试下一个 key,直到解密成功。 如果 `keyring` 中的 key 都无法解密数据,则使用原始数据。 diff --git a/t/cli/test_main.sh b/t/cli/test_main.sh index c0e7d90c094c..c6c00adf23da 100755 --- a/t/cli/test_main.sh +++ b/t/cli/test_main.sh @@ -303,6 +303,38 @@ fi echo "passed: env value quoting (#11467)" +# a 32 byte data encryption key selects AES-256 and must pass config validation +cat > conf/config.yaml <<'EOF' +apisix: + data_encryption: + keyring: + - qeddd145sfvddff3qeddd145sfvddff3 + - qeddd145sfvddff3 +EOF + +out=$(make init 2>&1 || true) +if echo "$out" | grep "failed to validate config"; then + echo "failed: a 32 byte data encryption keyring should be accepted" + exit 1 +fi + +# a key that is neither 16 nor 32 bytes would be dropped at runtime and silently +# leave the data unencrypted, so it has to be rejected on startup +cat > conf/config.yaml <<'EOF' +apisix: + data_encryption: + keyring: + - qeddd145sfvddff3qedd +EOF + +out=$(make init 2>&1 || true) +if ! echo "$out" | grep "failed to validate config"; then + echo "failed: a data encryption keyring of an unsupported length should be rejected" + exit 1 +fi + +echo "passed: data encryption keyring length validation" + # support environment variables echo ' nginx_config: diff --git a/t/core/data_encryption.t b/t/core/data_encryption.t new file mode 100644 index 000000000000..7c73eea6bfb3 --- /dev/null +++ b/t/core/data_encryption.t @@ -0,0 +1,155 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +repeat_each(2); +no_long_string(); +no_root_location(); + +run_tests; + +__DATA__ + +=== TEST 1: 16 byte key builds an AES-128 cipher and round-trips +--- config + location /t { + content_by_lua_block { + local data_encryption = require("apisix.core.data_encryption") + local iv_tbl = data_encryption.init_iv_tbl("qeddd145sfvddff3") + ngx.say("ciphers: ", #iv_tbl) + + local enc = data_encryption.aes_cbc_encrypt(iv_tbl, "hello world") + ngx.say("encrypted differs: ", enc ~= "hello world") + ngx.say("decrypted: ", data_encryption.aes_cbc_decrypt(iv_tbl, enc)) + } + } +--- request +GET /t +--- response_body +ciphers: 1 +encrypted differs: true +decrypted: hello world +--- no_error_log +[error] + + + +=== TEST 2: 32 byte key builds an AES-256 cipher and round-trips +--- config + location /t { + content_by_lua_block { + local data_encryption = require("apisix.core.data_encryption") + local iv_tbl = data_encryption.init_iv_tbl("qeddd145sfvddff3qeddd145sfvddff3") + ngx.say("ciphers: ", #iv_tbl) + + local enc = data_encryption.aes_cbc_encrypt(iv_tbl, "hello world") + ngx.say("encrypted differs: ", enc ~= "hello world") + ngx.say("decrypted: ", data_encryption.aes_cbc_decrypt(iv_tbl, enc)) + } + } +--- request +GET /t +--- response_body +ciphers: 1 +encrypted differs: true +decrypted: hello world +--- no_error_log +[error] + + + +=== TEST 3: rotating an AES-128 keyring to AES-256 keeps the old data readable +--- config + location /t { + content_by_lua_block { + local data_encryption = require("apisix.core.data_encryption") + + -- data written before the rotation, encrypted with the 16 byte key only + local legacy = data_encryption.init_iv_tbl("qeddd145sfvddff3") + local legacy_enc = data_encryption.aes_cbc_encrypt(legacy, "hello world") + + -- the new 32 byte key goes first, the old one is kept to read old data + local rotated = data_encryption.init_iv_tbl({ + "qeddd145sfvddff3qeddd145sfvddff3", + "qeddd145sfvddff3", + }) + ngx.say("ciphers: ", #rotated) + ngx.say("legacy data: ", data_encryption.aes_cbc_decrypt(rotated, legacy_enc)) + + -- new writes go through the first (AES-256) cipher + local enc = data_encryption.aes_cbc_encrypt(rotated, "hello world") + ngx.say("re-encrypted: ", enc ~= legacy_enc) + ngx.say("new data: ", data_encryption.aes_cbc_decrypt(rotated, enc)) + } + } +--- request +GET /t +--- response_body +ciphers: 2 +legacy data: hello world +re-encrypted: true +new data: hello world +--- no_error_log +[error] + + + +=== TEST 4: a key of an unsupported length is dropped and reported +--- config + location /t { + content_by_lua_block { + local data_encryption = require("apisix.core.data_encryption") + local iv_tbl = data_encryption.init_iv_tbl({ + "short", + "qeddd145sfvddff3", + }) + ngx.say("ciphers: ", #iv_tbl) + } + } +--- request +GET /t +--- response_body +ciphers: 1 +--- error_log +expected a 16 byte (AES-128) or 32 byte (AES-256) key, got 5 bytes + + + +=== TEST 5: a 32 byte keyring configured in config.yaml encrypts and decrypts +--- yaml_config +apisix: + node_listen: 1984 + data_encryption: + enable_encrypt_fields: true + keyring: + - qeddd145sfvddff3qeddd145sfvddff3 +--- config + location /t { + content_by_lua_block { + local data_encryption = require("apisix.core.data_encryption") + local enc = data_encryption.encrypt("hello world") + ngx.say("encrypted: ", enc ~= "hello world") + ngx.say("decrypted: ", data_encryption.decrypt(enc)) + } + } +--- request +GET /t +--- response_body +encrypted: true +decrypted: hello world +--- no_error_log +[error]