Skip to content

Commit b8bbee8

Browse files
author
Memo Ugurbil
committed
feat: encrypt & decrypt with seeds
1 parent fe8a7e8 commit b8bbee8

File tree

4 files changed

+1266
-2
lines changed

4 files changed

+1266
-2
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ requires-python = ">=3.9"
88
dependencies = [
99
"lagrange~=3.0",
1010
"bcl~=2.3",
11-
"pailliers~=0.1"
11+
"pailliers~=0.1",
12+
"pytest>=8.3.5",
1213
]
1314

1415
[project.urls]

src/nilql/nilql.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ def generate(
362362

363363
if (
364364
(not isinstance(operations, dict)) or
365-
(not set(operations.keys()).issubset({'store', 'match', 'sum'}))
365+
(not set(operations.keys()).issubset({'store', 'seed', 'match', 'sum'}))
366366
):
367367
raise ValueError('valid operations specification is required')
368368

@@ -393,6 +393,14 @@ def generate(
393393
bytes.__new__(bcl.secret, _random_bytes(32, seed))
394394
)
395395

396+
if secret_key['operations'].get('seed'):
397+
# Symmetric key for encrypting the plaintext or the shares of a plaintext.
398+
secret_key['material'] = (
399+
bcl.symmetric.secret()
400+
if seed is None else
401+
bytes.__new__(bcl.secret, _random_bytes(32, seed))
402+
)
403+
396404
if secret_key['operations'].get('match'):
397405
# Salt for deterministic hashing of the plaintext.
398406
secret_key['material'] = _random_bytes(64, seed)
@@ -743,6 +751,33 @@ def encrypt(
743751
))
744752
return list(map(_pack, shares))
745753

754+
# Encrypt a plaintext for storage and retrieval.
755+
if key['operations'].get('seed'):
756+
# For single-node clusters, the data is encrypted using a symmetric key.
757+
if len(key['cluster']['nodes']) == 1:
758+
return _pack(
759+
bcl.symmetric.encrypt(key['material'], bcl.plain(buffer))
760+
)
761+
762+
# For multiple-node clusters, the ciphertext is secret-shared using XOR
763+
# (with each share symmetrically encrypted in the case of a secret key).
764+
optional_enc = (
765+
(lambda s: bcl.symmetric.encrypt(key['material'], bcl.plain(s)))
766+
if 'material' in key else
767+
(lambda s: s)
768+
)
769+
seeds = []
770+
aggregate = bytes(len(buffer))
771+
for _ in range(len(key['cluster']['nodes']) - 1):
772+
seed = _random_bytes(64)
773+
seeds.append(optional_enc(seed))
774+
mask = _random_bytes(len(buffer), seed)
775+
aggregate = bytes(a ^ b for (a, b) in zip(aggregate, mask))
776+
share = optional_enc(
777+
bytes(a ^ b for (a, b) in zip(aggregate, buffer))
778+
)
779+
return list(map(_pack, seeds)) + [_pack(share)]
780+
746781
# Encrypt (i.e., hash) a plaintext for matching.
747782
if key['operations'].get('match'):
748783
# The deterministic salted hash of the encoded plaintext is the ciphertext.
@@ -949,6 +984,40 @@ def decrypt(
949984

950985
return _decode(bytes_)
951986

987+
# Decrypt a value that was encrypted for storage and retrieval.
988+
if key['operations'].get('seed'):
989+
# For single-node clusters, the plaintext is encrypted using a symmetric key.
990+
if len(key['cluster']['nodes']) == 1:
991+
try:
992+
return _decode(
993+
bcl.symmetric.decrypt(
994+
key['material'],
995+
bcl.cipher(_unpack(ciphertext))
996+
)
997+
)
998+
except Exception as exc:
999+
raise error from exc
1000+
1001+
# For multiple-node clusters, the ciphertext is secret-shared using XOR
1002+
# (with each share symmetrically encrypted in the case of a secret key).
1003+
shares = [_unpack(share) for share in ciphertext]
1004+
if 'material' in key:
1005+
try:
1006+
shares = [
1007+
bcl.symmetric.decrypt(key['material'], bcl.cipher(share))
1008+
for share in shares
1009+
]
1010+
except Exception as exc:
1011+
raise error from exc
1012+
1013+
bytes_ = bytes(len(shares[-1]))
1014+
for share_ in shares[:-1]:
1015+
share_ = _random_bytes(len(bytes_), share_)
1016+
bytes_ = bytes(a ^ b for (a, b) in zip(bytes_, share_))
1017+
bytes_ = bytes(a ^ b for (a, b) in zip(bytes_, shares[-1]))
1018+
1019+
return _decode(bytes_)
1020+
9521021
# Decrypt a value that was encrypted in a summation-compatible way.
9531022
if key['operations'].get('sum'):
9541023
# For single-node clusters, the Paillier cryptosystem is used.

test/test_nilql.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,21 @@ def test_encrypt_decrypt_for_store(self):
309309
decrypted = nilql.decrypt(sk, nilql.encrypt(sk, plaintext))
310310
self.assertEqual(decrypted, plaintext)
311311

312+
def test_encrypt_decrypt_for_seed(self):
313+
"""
314+
Test encryption and decryption for storing.
315+
"""
316+
for cluster in [{'nodes': [{}]}, {'nodes': [{}, {}, {}]}]:
317+
sk = nilql.SecretKey.generate(cluster, {'seed': True})
318+
319+
plaintext = 123
320+
decrypted = nilql.decrypt(sk, nilql.encrypt(sk, plaintext))
321+
self.assertEqual(decrypted, plaintext)
322+
323+
plaintext = 'abc'
324+
decrypted = nilql.decrypt(sk, nilql.encrypt(sk, plaintext))
325+
self.assertEqual(decrypted, plaintext)
326+
312327
def test_encrypt_for_match(self):
313328
"""
314329
Test encryption for matching.

0 commit comments

Comments
 (0)