Skip to content

Commit ce9daf7

Browse files
authored
feat(crypto): decrypt secret objects (#482)
Add support for `isSecret` objects and arrays in input schema and the new form of encrypted value: ``` ENCRYPTED_JSON_VALUE:{FIELD_SCHEMA_HASH}:{ENCRYPTED_PASSWORD}:{ENCRYPTED_VALUE} ``` More context here apify/apify-shared-js#515
1 parent 59b50d1 commit ce9daf7

File tree

3 files changed

+36
-10
lines changed

3 files changed

+36
-10
lines changed

src/apify/_consts.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
EVENT_LISTENERS_TIMEOUT = timedelta(seconds=5)
77

88
BASE64_REGEXP = '[-A-Za-z0-9+/]*={0,3}'
9-
ENCRYPTED_INPUT_VALUE_PREFIX = 'ENCRYPTED_VALUE'
10-
ENCRYPTED_INPUT_VALUE_REGEXP = re.compile(f'^{ENCRYPTED_INPUT_VALUE_PREFIX}:({BASE64_REGEXP}):({BASE64_REGEXP})$')
9+
ENCRYPTED_STRING_VALUE_PREFIX = 'ENCRYPTED_VALUE'
10+
ENCRYPTED_JSON_VALUE_PREFIX = 'ENCRYPTED_JSON'
11+
ENCRYPTED_INPUT_VALUE_REGEXP = re.compile(f'^({ENCRYPTED_STRING_VALUE_PREFIX}|{ENCRYPTED_JSON_VALUE_PREFIX}):(?:({BASE64_REGEXP}):)?({BASE64_REGEXP}):({BASE64_REGEXP})$')

src/apify/_crypto.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import hashlib
55
import hmac
6+
import json
67
import string
78
from typing import Any
89

@@ -14,7 +15,7 @@
1415
from apify_shared.utils import ignore_docs
1516
from crawlee._utils.crypto import crypto_random_object_id
1617

17-
from apify._consts import ENCRYPTED_INPUT_VALUE_REGEXP
18+
from apify._consts import ENCRYPTED_INPUT_VALUE_REGEXP, ENCRYPTED_STRING_VALUE_PREFIX, ENCRYPTED_JSON_VALUE_PREFIX
1819

1920
ENCRYPTION_KEY_LENGTH = 32
2021
ENCRYPTION_IV_LENGTH = 16
@@ -147,14 +148,20 @@ def decrypt_input_secrets(private_key: rsa.RSAPrivateKey, input_data: Any) -> An
147148
if isinstance(value, str):
148149
match = ENCRYPTED_INPUT_VALUE_REGEXP.fullmatch(value)
149150
if match:
150-
encrypted_password = match.group(1)
151-
encrypted_value = match.group(2)
152-
input_data[key] = private_decrypt(
151+
prefix = match.group(1)
152+
encrypted_password = match.group(3)
153+
encrypted_value = match.group(4)
154+
decrypted_value = private_decrypt(
153155
encrypted_password,
154156
encrypted_value,
155157
private_key=private_key,
156158
)
157159

160+
if prefix == ENCRYPTED_STRING_VALUE_PREFIX:
161+
input_data[key] = decrypted_value
162+
elif prefix == ENCRYPTED_JSON_VALUE_PREFIX:
163+
input_data[key] = json.loads(decrypted_value)
164+
158165
return input_data
159166

160167

tests/unit/actor/test_actor_key_value_store.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ..test_crypto import PRIVATE_KEY_PASSWORD, PRIVATE_KEY_PEM_BASE64, PUBLIC_KEY
1111
from apify import Actor
12-
from apify._consts import ENCRYPTED_INPUT_VALUE_PREFIX
12+
from apify._consts import ENCRYPTED_STRING_VALUE_PREFIX, ENCRYPTED_JSON_VALUE_PREFIX
1313
from apify._crypto import public_encrypt
1414

1515
if TYPE_CHECKING:
@@ -74,11 +74,26 @@ async def test_get_input_with_encrypted_secrets(
7474
monkeypatch.setenv(ApifyEnvVars.INPUT_SECRETS_PRIVATE_KEY_PASSPHRASE, PRIVATE_KEY_PASSWORD)
7575

7676
input_key = 'INPUT'
77+
secret_string_legacy = 'secret-string'
7778
secret_string = 'secret-string'
78-
encrypted_secret = public_encrypt(secret_string, public_key=PUBLIC_KEY)
79+
secret_object = {'foo': 'bar', 'baz': 'qux'}
80+
secret_array = ['foo', 'bar', 'baz']
81+
82+
# The legacy encryption format uses ENCRYPTED_STRING_VALUE_PREFIX prefix, value in raw string and does not include schemahash.
83+
# The new format uses ENCRYPTED_JSON_VALUE_PREFIX prefix, value in JSON format and includes schemahash.
84+
# We are testing both formats to ensure backward compatibility.
85+
86+
encrypted_string_legacy = public_encrypt(secret_string_legacy, public_key=PUBLIC_KEY)
87+
encrypted_string = public_encrypt(json_dumps(secret_string), public_key=PUBLIC_KEY)
88+
encrypted_object = public_encrypt(json_dumps(secret_object), public_key=PUBLIC_KEY)
89+
encrypted_array = public_encrypt(json_dumps(secret_array), public_key=PUBLIC_KEY)
90+
7991
input_with_secret = {
8092
'foo': 'bar',
81-
'secret': f'{ENCRYPTED_INPUT_VALUE_PREFIX}:{encrypted_secret["encrypted_password"]}:{encrypted_secret["encrypted_value"]}', # noqa: E501
93+
'secret_string_legacy': f'{ENCRYPTED_STRING_VALUE_PREFIX}:{encrypted_string_legacy["encrypted_password"]}:{encrypted_string_legacy["encrypted_value"]}',
94+
'secret_string': f'{ENCRYPTED_JSON_VALUE_PREFIX}:schemahash:{encrypted_string["encrypted_password"]}:{encrypted_string["encrypted_value"]}',
95+
'secret_object': f'{ENCRYPTED_JSON_VALUE_PREFIX}:schemahash:{encrypted_object["encrypted_password"]}:{encrypted_object["encrypted_value"]}',
96+
'secret_array': f'{ENCRYPTED_JSON_VALUE_PREFIX}:schemahash:{encrypted_array["encrypted_password"]}:{encrypted_array["encrypted_value"]}',
8297
}
8398

8499
await memory_storage_client.key_value_stores().get_or_create(id='default')
@@ -91,4 +106,7 @@ async def test_get_input_with_encrypted_secrets(
91106
async with Actor as my_actor:
92107
input = await my_actor.get_input() # noqa: A001
93108
assert input['foo'] == input_with_secret['foo']
94-
assert input['secret'] == secret_string
109+
assert input['secret_string_legacy'] == secret_string_legacy
110+
assert input['secret_string'] == secret_string
111+
assert input['secret_object'] == secret_object
112+
assert input['secret_array'] == secret_array

0 commit comments

Comments
 (0)