From ca59a49d73abc3edd08e65b7affde63d66ce8c30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 05:52:37 +0000 Subject: [PATCH 01/35] build(deps): bump six from 1.16.0 to 1.17.0 Bumps [six](https://github.com/benjaminp/six) from 1.16.0 to 1.17.0. - [Changelog](https://github.com/benjaminp/six/blob/main/CHANGES) - [Commits](https://github.com/benjaminp/six/compare/1.16.0...1.17.0) --- updated-dependencies: - dependency-name: six dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bf5c16a..c1695ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,5 @@ cryptography==44.0.0 ecdsa==0.19.0 pycparser==2.22 pycryptodome==3.21.0 -six==1.16.0 +six==1.17.0 sqlcipher3==0.5.4 From 3ab33342d5dbb2dfff77069b7c91ee6fa72ee12e Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 10 Dec 2024 09:36:44 +0100 Subject: [PATCH 02/35] chore: bump version from 0.1.1 to 0.1.2 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6da28dd..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 \ No newline at end of file +0.1.2 From f157326b45bbffe7345df3e40fbbd219c6b99429 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 05:35:09 +0000 Subject: [PATCH 03/35] build(deps): bump ecdsa from 0.19.0 to 0.19.1 Bumps [ecdsa](https://github.com/tlsfuzzer/python-ecdsa) from 0.19.0 to 0.19.1. - [Release notes](https://github.com/tlsfuzzer/python-ecdsa/releases) - [Changelog](https://github.com/tlsfuzzer/python-ecdsa/blob/master/NEWS) - [Commits](https://github.com/tlsfuzzer/python-ecdsa/compare/python-ecdsa-0.19.0...python-ecdsa-0.19.1) --- updated-dependencies: - dependency-name: ecdsa dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c1695ce..be7429b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ cffi==1.17.1 cryptography==44.0.0 -ecdsa==0.19.0 +ecdsa==0.19.1 pycparser==2.22 pycryptodome==3.21.0 six==1.17.0 From 810992096f7928e426e87f681f19f93eb271765f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Mar 2025 09:50:56 +0000 Subject: [PATCH 04/35] build(deps): bump pycryptodome from 3.21.0 to 3.22.0 Bumps [pycryptodome](https://github.com/Legrandin/pycryptodome) from 3.21.0 to 3.22.0. - [Release notes](https://github.com/Legrandin/pycryptodome/releases) - [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst) - [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.21.0...v3.22.0) --- updated-dependencies: - dependency-name: pycryptodome dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index be7429b..ee21c8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,6 @@ cffi==1.17.1 cryptography==44.0.0 ecdsa==0.19.1 pycparser==2.22 -pycryptodome==3.21.0 +pycryptodome==3.22.0 six==1.17.0 sqlcipher3==0.5.4 From e11d0644ccb030fd8971577de7cbafdc0395d148 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Mar 2025 09:54:38 +0000 Subject: [PATCH 05/35] build(deps): bump cryptography from 44.0.0 to 44.0.2 Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.0 to 44.0.2. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/44.0.0...44.0.2) --- updated-dependencies: - dependency-name: cryptography dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ee21c8e..19bbcea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==1.17.1 -cryptography==44.0.0 +cryptography==44.0.2 ecdsa==0.19.1 pycparser==2.22 pycryptodome==3.22.0 From aefd4c74f23a09476335c03e05d83b1c77cf05db Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Wed, 26 Mar 2025 11:05:05 +0100 Subject: [PATCH 06/35] build(deps): bump version from 0.1.2 to 0.1.3. --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index d917d3e..b1e80bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.1.3 From 78c65207bd3da2cc1a20906a8eb3c13f506a0829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 06:21:40 +0000 Subject: [PATCH 07/35] build(deps): bump cryptography from 44.0.2 to 44.0.3 Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.2 to 44.0.3. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/44.0.2...44.0.3) --- updated-dependencies: - dependency-name: cryptography dependency-version: 44.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 19bbcea..c739066 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==1.17.1 -cryptography==44.0.2 +cryptography==44.0.3 ecdsa==0.19.1 pycparser==2.22 pycryptodome==3.22.0 From 9bf68f55f9b788cc4efe21f27a94ee5948da0c74 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 6 May 2025 11:21:21 +0100 Subject: [PATCH 08/35] build(deps): bump version from 0.1.3 to 0.1.4. --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b1e80bb..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4 From 02e673ac46c83340da0e3586686beffa288ddf9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 06:06:59 +0000 Subject: [PATCH 09/35] build(deps): bump cryptography from 44.0.3 to 45.0.3 Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.3 to 45.0.3. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/44.0.3...45.0.3) --- updated-dependencies: - dependency-name: cryptography dependency-version: 45.0.3 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c739066..fe241e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==1.17.1 -cryptography==44.0.3 +cryptography==45.0.3 ecdsa==0.19.1 pycparser==2.22 pycryptodome==3.22.0 From 7080945208bd61c5c110fc16523078117b10ba20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 18:36:29 +0000 Subject: [PATCH 10/35] build(deps): bump pycryptodome from 3.22.0 to 3.23.0 Bumps [pycryptodome](https://github.com/Legrandin/pycryptodome) from 3.22.0 to 3.23.0. - [Release notes](https://github.com/Legrandin/pycryptodome/releases) - [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst) - [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.22.0...v3.23.0) --- updated-dependencies: - dependency-name: pycryptodome dependency-version: 3.23.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fe241e2..742d971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,6 @@ cffi==1.17.1 cryptography==45.0.3 ecdsa==0.19.1 pycparser==2.22 -pycryptodome==3.22.0 +pycryptodome==3.23.0 six==1.17.0 sqlcipher3==0.5.4 From b8f0680c151beb64ad1091f6d8c08082fa66b719 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 27 May 2025 19:43:37 +0100 Subject: [PATCH 11/35] build(deps): bump version from 0.1.4 to 0.1.5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 845639e..9faa1b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4 +0.1.5 From 100be0db3390473552f35394b93facbf7efed48c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 06:08:58 +0000 Subject: [PATCH 12/35] build(deps): bump cryptography from 45.0.3 to 45.0.5 Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.3 to 45.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.3...45.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-version: 45.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 742d971..efea207 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==1.17.1 -cryptography==45.0.3 +cryptography==45.0.5 ecdsa==0.19.1 pycparser==2.22 pycryptodome==3.23.0 From 2fe3209961fc579279dd4b10ee8b1ef578e6455c Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 15 Jul 2025 10:33:23 +0100 Subject: [PATCH 13/35] build(deps): bump version from 0.1.5 to 0.1.6 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 9faa1b7..c946ee6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 From 819dfbe78f2cb15fd911ea01d0e50423aa451015 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 08:56:12 +0000 Subject: [PATCH 14/35] build(deps): bump cryptography from 45.0.5 to 45.0.6 Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.5 to 45.0.6. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.5...45.0.6) --- updated-dependencies: - dependency-name: cryptography dependency-version: 45.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index efea207..2995724 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==1.17.1 -cryptography==45.0.5 +cryptography==45.0.6 ecdsa==0.19.1 pycparser==2.22 pycryptodome==3.23.0 From 2c028c83b900bcc16f81395b6c4338de60cbf89f Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 12 Aug 2025 12:15:27 +0100 Subject: [PATCH 15/35] build(deps): bump version from 0.1.6 to 0.1.7 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c946ee6..1180819 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.6 +0.1.7 From 03706de087de0039290bb9433663fea59ad7f002 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 05:30:26 +0000 Subject: [PATCH 16/35] build(deps): bump cffi from 1.17.1 to 2.0.0 Bumps [cffi](https://github.com/python-cffi/cffi) from 1.17.1 to 2.0.0. - [Release notes](https://github.com/python-cffi/cffi/releases) - [Commits](https://github.com/python-cffi/cffi/compare/v1.17.1...v2.0.0) --- updated-dependencies: - dependency-name: cffi dependency-version: 2.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2995724..26cfabc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cffi==1.17.1 +cffi==2.0.0 cryptography==45.0.6 ecdsa==0.19.1 pycparser==2.22 From 1188eba91d34e2232c15749b43ada091b9f09591 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:34:47 +0000 Subject: [PATCH 17/35] build(deps): bump pycparser from 2.22 to 2.23 Bumps [pycparser](https://github.com/eliben/pycparser) from 2.22 to 2.23. - [Release notes](https://github.com/eliben/pycparser/releases) - [Changelog](https://github.com/eliben/pycparser/blob/main/CHANGES) - [Commits](https://github.com/eliben/pycparser/compare/release_v2.22...release_v2.23) --- updated-dependencies: - dependency-name: pycparser dependency-version: '2.23' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 26cfabc..4c72929 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ cffi==2.0.0 cryptography==45.0.6 ecdsa==0.19.1 -pycparser==2.22 +pycparser==2.23 pycryptodome==3.23.0 six==1.17.0 sqlcipher3==0.5.4 From 51615fe60d6b47a951b299f230dcb9564bc17867 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:37:00 +0000 Subject: [PATCH 18/35] build(deps): bump cryptography from 45.0.6 to 45.0.7 Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.6 to 45.0.7. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.6...45.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 45.0.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4c72929..46e5628 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==2.0.0 -cryptography==45.0.6 +cryptography==45.0.7 ecdsa==0.19.1 pycparser==2.23 pycryptodome==3.23.0 From 6e7b33ee08fe5af417f3e3d8db19b98b9b5c2923 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 16 Sep 2025 11:20:23 +0100 Subject: [PATCH 19/35] build(deps): bump version from 0.1.7 to 0.1.8 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1180819..699c6c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.7 +0.1.8 From 1f3d4b1f6547e7c85093b048e6ffaa186a038ca7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 05:20:21 +0000 Subject: [PATCH 20/35] build(deps): bump cryptography from 45.0.7 to 46.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.7 to 46.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.7...46.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 46e5628..91fb5a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==2.0.0 -cryptography==45.0.7 +cryptography==46.0.1 ecdsa==0.19.1 pycparser==2.23 pycryptodome==3.23.0 From ecfa907afe1e41de743654046833772d8a2bd738 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 23 Sep 2025 12:44:33 +0100 Subject: [PATCH 21/35] build(deps): bump version from 0.1.8 to 0.1.9 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 699c6c6..1a03094 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.8 +0.1.9 From 37e2f77a8cb49cae4ebd259023fac094ffac306e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 05:20:25 +0000 Subject: [PATCH 22/35] build(deps): bump cryptography from 46.0.1 to 46.0.2 Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.1 to 46.0.2. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.1...46.0.2) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91fb5a4..03ac218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==2.0.0 -cryptography==46.0.1 +cryptography==46.0.2 ecdsa==0.19.1 pycparser==2.23 pycryptodome==3.23.0 From 61d8ba256b2001e91e5a4fa7d4691229c7654e25 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 7 Oct 2025 10:54:02 +0100 Subject: [PATCH 23/35] build(deps): bump version from 0.1.9 to 0.1.10 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1a03094..9767cc9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.9 +0.1.10 From ea2fbeb7733812953494284c1a890dc82ba17e52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 05:24:07 +0000 Subject: [PATCH 24/35] build(deps): bump cryptography from 46.0.2 to 46.0.3 Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.2 to 46.0.3. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.2...46.0.3) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 03ac218..a630a96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cffi==2.0.0 -cryptography==46.0.2 +cryptography==46.0.3 ecdsa==0.19.1 pycparser==2.23 pycryptodome==3.23.0 From 37825399f761da8686a9ce8a86798fbe6ed512eb Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 21 Oct 2025 11:09:00 +0100 Subject: [PATCH 25/35] build(deps): bump version from 0.1.10 to 0.1.11 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 9767cc9..20f4951 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.10 +0.1.11 From 8e07cbdf8f92cfb7acbcc5a9b4da0e99aa6ec191 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Nov 2025 12:36:43 +0100 Subject: [PATCH 26/35] refactor(keypairs): Use constant-time comparison for secret keys in equality checks --- smswithoutborders_libsig/keypairs.py | 132 ++++++++++++++++----------- 1 file changed, 77 insertions(+), 55 deletions(-) diff --git a/smswithoutborders_libsig/keypairs.py b/smswithoutborders_libsig/keypairs.py index 92f5169..aa8ed63 100755 --- a/smswithoutborders_libsig/keypairs.py +++ b/smswithoutborders_libsig/keypairs.py @@ -1,25 +1,21 @@ #!/usr/bin/env python3 +import base64 +import secrets +import struct +import uuid from abc import ABC, abstractmethod -# ECDH -from ecdsa import ECDH, NIST256p -from cryptography.hazmat.primitives.kdf.hkdf import HKDF - -# X25519 -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives import serialization -import binascii -import base64 +from cryptography.hazmat.primitives import constant_time, hashes +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from ecdsa import ECDH, NIST256p from smswithoutborders_libsig.keystore import Keystore -import base64 -import secrets -import uuid -import struct class Keypairs(ABC): size = 32 @@ -48,14 +44,23 @@ def serialize(self): def deserialize(self): pass - def store(pk, _pk, keystore_path, pnt_keystore, info=b"x25591_key_exchange", - salt=None, secret_key=None): + def store( + pk, + _pk, + keystore_path, + pnt_keystore, + info=b"x25591_key_exchange", + salt=None, + secret_key=None, + ): if not secret_key: - secret_key = secrets.token_bytes(Keypairs.size) # store this - extended_derived_key = HKDF(algorithm=hashes.SHA256(), - length=Keypairs.size, - salt=salt, - info=info,).derive(secret_key) + secret_key = secrets.token_bytes(Keypairs.size) # store this + extended_derived_key = HKDF( + algorithm=hashes.SHA256(), + length=Keypairs.size, + salt=salt, + info=info, + ).derive(secret_key) secret_key = base64.b64encode(extended_derived_key).decode() keystore = Keystore(keystore_path, secret_key) @@ -66,11 +71,14 @@ def store(pk, _pk, keystore_path, pnt_keystore, info=b"x25591_key_exchange", def fetch(pnt_keystore, secret_key, keystore_path=None): keystore = Keystore(keystore_path, secret_key) return keystore.fetch(pnt_keystore) - def __agree__(secret_key, info=b"x25591_key_exchange", salt=None): - return HKDF(algorithm=hashes.SHA256(), - length=Keypairs.size, salt=salt, info=info,).derive(secret_key) + return HKDF( + algorithm=hashes.SHA256(), + length=Keypairs.size, + salt=salt, + info=info, + ).derive(secret_key) class ecdh(Keypairs): @@ -86,23 +94,25 @@ def init(self): if not self.keystore_path: self.keystore_path = f"db_keys/{self.pnt_keystore}.db" - self.secret_key = Keypairs.store(pk.to_string(), - ecdh.private_key.to_string(), - self.keystore_path, - self.pnt_keystore) + self.secret_key = Keypairs.store( + pk.to_string(), + ecdh.private_key.to_string(), + self.keystore_path, + self.pnt_keystore, + ) return pk.to_string() def get_public_key(self): - ppk = Keypairs.fetch(self.pnt_keystore, self.secret_key, self.keystore_path ) + ppk = Keypairs.fetch(self.pnt_keystore, self.secret_key, self.keystore_path) return ppk[0] - + def load_keystore(self): pass def agree(self, public_key, info=b"x25591_key_exchange", salt=None) -> bytes: if not self.keystore_path: self.keystore_path = f"db_keys/{pnt_keystore}.db" - ppk = Keypairs.fetch(self.pnt_keystore, self.secret_key, self.keystore_path ) + ppk = Keypairs.fetch(self.pnt_keystore, self.secret_key, self.keystore_path) if ppk: ecdh = ECDH(curve=NIST256p) ecdh.load_private_key_bytes(ppk[1]) @@ -133,56 +143,68 @@ def init(self): if not self.keystore_path: self.keystore_path = f"db_keys/{self.pnt_keystore}.db" - self.secret_key = Keypairs.store(pk, _pk, self.keystore_path, - self.pnt_keystore, secret_key=self.secret_key) + self.secret_key = Keypairs.store( + pk, _pk, self.keystore_path, self.pnt_keystore, secret_key=self.secret_key + ) return pk def serialize(self) -> bytes: - """ - """ - if not hasattr(self, 'pnt_keystore') or self.pnt_keystore == None or \ - not hasattr(self, 'keystore_path') or self.keystore_path == None or \ - not hasattr(self, 'secret_key') or self.secret_key == None: + """ """ + if ( + not hasattr(self, "pnt_keystore") + or self.pnt_keystore == None + or not hasattr(self, "keystore_path") + or self.keystore_path == None + or not hasattr(self, "secret_key") + or self.secret_key == None + ): raise Exception("keypair not initialized -- init()") keystore_path_len = len(self.keystore_path) pnt_keystore_len = len(self.pnt_keystore) - return struct.pack(" Keypairs: - """ - """ + """ """ x = x25519() keystore_path_len, pnt_keystore_len = struct.unpack(" bytes: dk = client1.agree(client2_public_key) dk1 = client2.agree(client1_public_key) - assert(dk != None) - assert(dk1 != None) - assert(dk == dk1) + assert dk != None + assert dk1 != None + assert dk == dk1 s_c1 = client1.serialize() d_c1 = client1.deserialize(s_c1) - assert(d_c1 == client1) + assert d_c1 == client1 From 83d4d7cddf0d75ae0e1e7a76567b5fca7bdf8a93 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Nov 2025 12:58:54 +0100 Subject: [PATCH 27/35] refactor(protocols): improve formatting and readability --- smswithoutborders_libsig/protocols.py | 152 ++++++++++++++++---------- 1 file changed, 94 insertions(+), 58 deletions(-) diff --git a/smswithoutborders_libsig/protocols.py b/smswithoutborders_libsig/protocols.py index c693594..69271e2 100755 --- a/smswithoutborders_libsig/protocols.py +++ b/smswithoutborders_libsig/protocols.py @@ -1,18 +1,16 @@ #!/usr/bin/env python3 -from smswithoutborders_libsig.keypairs import Keypairs, x25519 +import pickle +import struct -from Crypto.Protocol.KDF import HKDF -from Crypto.Random import get_random_bytes -from Crypto.Hash import SHA512, SHA256, HMAC from Crypto.Cipher import AES +from Crypto.Hash import HMAC, SHA256, SHA512 +from Crypto.Protocol.KDF import HKDF from Crypto.Util.Padding import pad, unpad -import logging -import struct import smswithoutborders_libsig.helpers as helpers -import pickle -import base64 +from smswithoutborders_libsig.keypairs import Keypairs, x25519 + class States: DHs: Keypairs = None @@ -30,51 +28,78 @@ class States: MKSKIPPED = {} def serialize(self) -> bytes: - if not hasattr(self, 'DHs') or self.DHs == None or \ - not hasattr(self, 'RK') or self.RK == None: - raise Exception("State cannot be serialized: reason DHs == None or RK == None") + if ( + not hasattr(self, "DHs") + or self.DHs == None + or not hasattr(self, "RK") + or self.RK == None + ): + raise Exception( + "State cannot be serialized: reason DHs == None or RK == None" + ) s_keypairs = self.DHs.serialize() s_keypairs_len = len(s_keypairs) - dhr_len = len(self.DHr) if not self.DHr is None else 0 + dhr_len = len(self.DHr) if not self.DHr is None else 0 rk_len = len(self.RK) if not self.RK is None else 0 - ck_len = len(self.CKs) if not self.CKs is None else 0 - cr_len = len(self.CKr) if not self.CKr is None else 0 + ck_len = len(self.CKs) if not self.CKs is None else 0 + cr_len = len(self.CKr) if not self.CKr is None else 0 - len_start = struct.pack(f"<{'i'*5}", s_keypairs_len, rk_len, dhr_len, ck_len, cr_len) + len_start = struct.pack( + f"<{'i' * 5}", s_keypairs_len, rk_len, dhr_len, ck_len, cr_len + ) _serialized = len_start + s_keypairs + self.RK for i in [self.DHr, self.CKs, self.CKr]: - if i: + if i: _serialized = _serialized + i - _serialized = _serialized + struct.pack(" bytes: +def GENERATE_DH(keystore_path: str = None, secret_key=None) -> bytes: x = x25519(keystore_path=keystore_path, secret_key=secret_key) x.init() return x + def DH(dh_pair: Keypairs, dh_pub: bytes) -> bytes: return dh_pair.agree(dh_pub) -def KDF_RK(rk, dh_out): - length=32 - num_keys=2 + +def KDF_RK(rk, dh_out): + length = 32 + num_keys = 2 # TODO: make meaninful information - information=b'KDF_RK' + information = b"KDF_RK" - return HKDF(master=dh_out, - key_len=length, - salt=rk, - hashmod=SHA512, - num_keys=num_keys, context=information) + return HKDF( + master=dh_out, + key_len=length, + salt=rk, + hashmod=SHA512, + num_keys=num_keys, + context=information, + ) def KDF_CK(ck): d_ck = HMAC.new(ck, digestmod=SHA256) - _ck = d_ck.update(b'\x01').digest() + _ck = d_ck.update(b"\x01").digest() d_ck = HMAC.new(ck, digestmod=SHA256) - mk = d_ck.update(b'\x02').digest() + mk = d_ck.update(b"\x02").digest() return _ck, mk def ENCRYPT(mk, plaintext, associated_data) -> bytes: key, auth_key, iv = helpers.get_mac_parameters(mk) cipher = AES.new(key, AES.MODE_CBC, iv) - cipher_text = cipher.encrypt(pad(plaintext, AES.block_size)) + cipher_text = cipher.encrypt(pad(plaintext, AES.block_size)) hmac = helpers.build_verification_hash(auth_key, associated_data, cipher_text) return cipher_text + hmac.digest() From 03df5fe98812e6fb7e3997402338034e8b032256 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Nov 2025 13:15:55 +0100 Subject: [PATCH 28/35] fix(protocols): use constant-time compare for secrets --- smswithoutborders_libsig/protocols.py | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/smswithoutborders_libsig/protocols.py b/smswithoutborders_libsig/protocols.py index 69271e2..1945548 100755 --- a/smswithoutborders_libsig/protocols.py +++ b/smswithoutborders_libsig/protocols.py @@ -7,6 +7,7 @@ from Crypto.Hash import HMAC, SHA256, SHA512 from Crypto.Protocol.KDF import HKDF from Crypto.Util.Padding import pad, unpad +from cryptography.hazmat.primitives import constant_time import smswithoutborders_libsig.helpers as helpers from smswithoutborders_libsig.keypairs import Keypairs, x25519 @@ -107,12 +108,25 @@ def __eq__(self, other): if not isinstance(other, States): return NotImplemented + dhr_equal = constant_time.bytes_eq( + self.DHr if self.DHr else b"", other.DHr if other.DHr else b"" + ) + rk_equal = constant_time.bytes_eq( + self.RK if self.RK else b"", other.RK if other.RK else b"" + ) + cks_equal = constant_time.bytes_eq( + self.CKs if self.CKs else b"", other.CKs if other.CKs else b"" + ) + ckr_equal = constant_time.bytes_eq( + self.CKr if self.CKr else b"", other.CKr if other.CKr else b"" + ) + return ( self.DHs == other.DHs - and self.DHr == other.DHr - and self.RK == other.RK - and self.CKs == other.CKs - and self.CKr == other.CKr + and dhr_equal + and rk_equal + and cks_equal + and ckr_equal and self.Ns == other.Ns and self.Nr == other.Nr and self.PN == other.PN @@ -151,7 +165,11 @@ def deserialize(data): return headers def __eq__(self, other): - return self.dh == other.dh and self.pn == other.pn and self.n == other.n + return ( + constant_time.bytes_eq(self.dh, other.dh) + and self.pn == other.pn + and self.n == other.n + ) class DHRatchet: From a8e2811dd1bffab4fd43cd25a9f31878a4ebf885 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Nov 2025 13:16:32 +0100 Subject: [PATCH 29/35] test(protocols): add comprehensive protocol tests --- tests/test_protocols.py | 558 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 tests/test_protocols.py diff --git a/tests/test_protocols.py b/tests/test_protocols.py new file mode 100644 index 0000000..87dae56 --- /dev/null +++ b/tests/test_protocols.py @@ -0,0 +1,558 @@ +#!/usr/bin/env python3 + +import os +import secrets +import pytest +from smswithoutborders_libsig.protocols import ( + States, + HEADERS, + DHRatchet, + GENERATE_DH, + DH, + KDF_RK, + KDF_CK, + ENCRYPT, + DECRYPT, + CONCAT, +) +from smswithoutborders_libsig.keypairs import x25519 + + +class TestStates: + """Test States class: serialization, deserialization, and equality""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path = "db_keys/test_states.db" + self.dh_keypair = x25519(keystore_path=self.keystore_path) + self.dh_keypair.init() + + def teardown_method(self): + """Cleanup test files""" + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) + + def test_state_initialization(self): + """Test States can be initialized with default values""" + state = States() + assert state.DHs is None + assert state.DHr is None + assert state.RK is None + assert state.CKs is None + assert state.CKr is None + assert state.Ns == 0 + assert state.Nr == 0 + assert state.PN == 0 + assert state.MKSKIPPED == {} + + def test_state_serialization(self): + """Test States can be serialized with required fields""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = secrets.token_bytes(32) + state.CKr = secrets.token_bytes(32) + state.Ns = 5 + state.Nr = 3 + state.PN = 2 + + serialized = state.serialize() + assert isinstance(serialized, bytes) + assert len(serialized) > 0 + + def test_state_serialization_without_required_fields(self): + """Test serialization fails without DHs or RK""" + state = States() + with pytest.raises(Exception, match="State cannot be serialized"): + state.serialize() + + state.DHs = self.dh_keypair + with pytest.raises(Exception, match="State cannot be serialized"): + state.serialize() + + def test_state_deserialization(self): + """Test States can be deserialized correctly""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = secrets.token_bytes(32) + state.CKr = secrets.token_bytes(32) + state.Ns = 5 + state.Nr = 3 + state.PN = 2 + state.MKSKIPPED = {(b"key1", 1): b"value1"} + + serialized = state.serialize() + deserialized = States.deserialize(serialized) + + assert deserialized == state + assert deserialized.Ns == 5 + assert deserialized.Nr == 3 + assert deserialized.PN == 2 + assert deserialized.MKSKIPPED == {(b"key1", 1): b"value1"} + + def test_state_serialization_with_none_values(self): + """Test States serialization handles None values""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = None + state.CKr = None + + serialized = state.serialize() + deserialized = States.deserialize(serialized) + + assert deserialized.DHr == state.DHr + assert deserialized.CKs is None + assert deserialized.CKr is None + + def test_state_equality_identical_states(self): + """Test two identical states are equal""" + state1 = States() + state1.DHs = self.dh_keypair + state1.RK = secrets.token_bytes(32) + state1.DHr = secrets.token_bytes(32) + state1.CKs = secrets.token_bytes(32) + state1.CKr = secrets.token_bytes(32) + + serialized = state1.serialize() + state2 = States.deserialize(serialized) + + assert state1 == state2 + + def test_state_equality_different_states(self): + """Test different states are not equal""" + state1 = States() + state1.DHs = self.dh_keypair + state1.RK = secrets.token_bytes(32) + state1.Ns = 1 + + state2 = States() + state2.DHs = self.dh_keypair + state2.RK = secrets.token_bytes(32) + state2.Ns = 2 + + assert state1 != state2 + + def test_state_equality_constant_time_comparison(self): + """Test state equality uses constant-time comparison for secrets""" + state1 = States() + state1.DHs = self.dh_keypair + state1.RK = b"secret_key_1" * 3 + state1.DHr = b"dh_remote_1" * 3 + + state2 = States() + state2.DHs = self.dh_keypair + state2.RK = b"secret_key_2" * 3 + state2.DHr = b"dh_remote_1" * 3 + + assert state1 != state2 + + def test_state_equality_with_non_state_object(self): + """Test equality with non-States object returns NotImplemented""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + + assert state.__eq__("not a state") == NotImplemented + assert state.__eq__(42) == NotImplemented + + +class TestHEADERS: + """Test HEADERS class: initialization, serialization, and equality""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path = "db_keys/test_headers.db" + self.dh_keypair = x25519(keystore_path=self.keystore_path) + self.dh_keypair.init() + + def teardown_method(self): + """Cleanup test files""" + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) + + def test_header_initialization_with_keypair(self): + """Test HEADERS initialization with DH keypair""" + header = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + assert header.dh == self.dh_keypair.get_public_key() + assert header.pn == 5 + assert header.n == 10 + + def test_header_initialization_without_keypair(self): + """Test HEADERS initialization without DH keypair""" + header = HEADERS(pn=3, n=7) + assert header.pn == 3 + assert header.n == 7 + assert not hasattr(header, "dh") or header.dh is None + + def test_header_serialization(self): + """Test HEADERS can be serialized""" + header = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + serialized = header.serialize() + + assert isinstance(serialized, bytes) + assert len(serialized) > 8 # 8 bytes for pn + n, plus dh key + + def test_header_deserialization(self): + """Test HEADERS can be deserialized correctly""" + header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + serialized = header1.serialize() + header2 = HEADERS.deserialize(serialized) + + assert header1 == header2 + assert header2.pn == 5 + assert header2.n == 10 + assert header2.dh == self.dh_keypair.get_public_key() + + def test_header_equality_identical_headers(self): + """Test two identical headers are equal""" + header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + serialized = header1.serialize() + header2 = HEADERS.deserialize(serialized) + + assert header1 == header2 + + def test_header_equality_different_headers(self): + """Test different headers are not equal""" + header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + header2 = HEADERS(dh_pair=self.dh_keypair, pn=6, n=10) + + assert header1 != header2 + + def test_header_equality_constant_time_comparison(self): + """Test header equality uses constant-time comparison for dh""" + keypair2 = x25519(keystore_path="db_keys/test_headers2.db") + keypair2.init() + + header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + header2 = HEADERS(dh_pair=keypair2, pn=5, n=10) + + assert header1 != header2 + + if os.path.exists("db_keys/test_headers2.db"): + os.remove("db_keys/test_headers2.db") + + +class TestDHRatchet: + """Test DHRatchet class and DH operations""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path1 = "db_keys/test_dh1.db" + self.keystore_path2 = "db_keys/test_dh2.db" + + def teardown_method(self): + """Cleanup test files""" + for path in [self.keystore_path1, self.keystore_path2]: + if os.path.exists(path): + os.remove(path) + + def test_generate_dh(self): + """Test DH keypair generation""" + dh = GENERATE_DH(keystore_path=self.keystore_path1) + assert dh is not None + assert hasattr(dh, "get_public_key") + assert len(dh.get_public_key()) == 32 + + def test_dh_agreement(self): + """Test DH key agreement between two parties""" + dh1 = GENERATE_DH(keystore_path=self.keystore_path1) + dh2 = GENERATE_DH(keystore_path=self.keystore_path2) + + shared1 = DH(dh1, dh2.get_public_key()) + shared2 = DH(dh2, dh1.get_public_key()) + + assert shared1 == shared2 + assert len(shared1) == 32 + + def test_dh_ratchet_updates_state(self): + """Test DHRatchet updates state correctly""" + state = States() + state.DHs = GENERATE_DH(keystore_path=self.keystore_path1) + state.RK = secrets.token_bytes(32) + state.Ns = 5 + state.Nr = 3 + state.PN = 2 + + dh_remote = GENERATE_DH(keystore_path=self.keystore_path2) + header = HEADERS(dh_pair=dh_remote, pn=0, n=0) + + DHRatchet(state, header) + + assert state.PN == 5 # Previous Ns + assert state.Ns == 0 # Reset + assert state.Nr == 0 # Reset + assert state.DHr == dh_remote.get_public_key() + assert state.CKr is not None + assert state.CKs is not None + assert len(state.RK) == 32 + + +class TestKDF: + """Test Key Derivation Functions""" + + def test_kdf_rk_generates_two_keys(self): + """Test KDF_RK generates root key and chain key""" + rk = secrets.token_bytes(32) + dh_out = secrets.token_bytes(32) + + new_rk, ck = KDF_RK(rk, dh_out) + + assert len(new_rk) == 32 + assert len(ck) == 32 + assert new_rk != rk + assert new_rk != ck + + def test_kdf_rk_deterministic(self): + """Test KDF_RK is deterministic with same inputs""" + rk = secrets.token_bytes(32) + dh_out = secrets.token_bytes(32) + + new_rk1, ck1 = KDF_RK(rk, dh_out) + new_rk2, ck2 = KDF_RK(rk, dh_out) + + assert new_rk1 == new_rk2 + assert ck1 == ck2 + + def test_kdf_ck_generates_chain_and_message_key(self): + """Test KDF_CK generates new chain key and message key""" + ck = secrets.token_bytes(32) + + new_ck, mk = KDF_CK(ck) + + assert len(new_ck) == 32 + assert len(mk) == 32 + assert new_ck != ck + assert new_ck != mk + + def test_kdf_ck_deterministic(self): + """Test KDF_CK is deterministic with same inputs""" + ck = secrets.token_bytes(32) + + new_ck1, mk1 = KDF_CK(ck) + new_ck2, mk2 = KDF_CK(ck) + + assert new_ck1 == new_ck2 + assert mk1 == mk2 + + def test_kdf_ck_chain_progression(self): + """Test KDF_CK can be chained for multiple message keys""" + ck = secrets.token_bytes(32) + + ck1, mk1 = KDF_CK(ck) + ck2, mk2 = KDF_CK(ck1) + ck3, mk3 = KDF_CK(ck2) + + assert ck != ck1 != ck2 != ck3 + assert mk1 != mk2 != mk3 + + +class TestEncryptionDecryption: + """Test ENCRYPT and DECRYPT functions""" + + def test_encrypt_decrypt_roundtrip(self): + """Test encryption and decryption roundtrip""" + mk = secrets.token_bytes(32) + plaintext = b"Hello, World! This is a test message." + associated_data = b"associated_data" + + ciphertext = ENCRYPT(mk, plaintext, associated_data) + decrypted = DECRYPT(mk, ciphertext, associated_data) + + assert decrypted == plaintext + + def test_encrypt_produces_different_output(self): + """Test plaintext and ciphertext are different""" + mk = secrets.token_bytes(32) + plaintext = b"Secret message" + associated_data = b"metadata" + + ciphertext = ENCRYPT(mk, plaintext, associated_data) + + assert ciphertext != plaintext + assert len(ciphertext) > len(plaintext) + + def test_encrypt_with_different_keys(self): + """Test encryption with different keys produces different outputs""" + mk1 = secrets.token_bytes(32) + mk2 = secrets.token_bytes(32) + plaintext = b"Secret message" + associated_data = b"metadata" + + ciphertext1 = ENCRYPT(mk1, plaintext, associated_data) + ciphertext2 = ENCRYPT(mk2, plaintext, associated_data) + + assert ciphertext1 != ciphertext2 + + def test_decrypt_with_wrong_key_fails(self): + """Test decryption with wrong key fails""" + mk1 = secrets.token_bytes(32) + mk2 = secrets.token_bytes(32) + plaintext = b"Secret message" + associated_data = b"metadata" + + ciphertext = ENCRYPT(mk1, plaintext, associated_data) + + with pytest.raises(ValueError): + DECRYPT(mk2, ciphertext, associated_data) + + def test_decrypt_with_wrong_associated_data_fails(self): + """Test decryption with wrong associated data fails""" + mk = secrets.token_bytes(32) + plaintext = b"Secret message" + associated_data1 = b"metadata1" + associated_data2 = b"metadata2" + + ciphertext = ENCRYPT(mk, plaintext, associated_data1) + + with pytest.raises(ValueError): + DECRYPT(mk, ciphertext, associated_data2) + + def test_decrypt_with_tampered_ciphertext_fails(self): + """Test decryption with tampered ciphertext fails""" + mk = secrets.token_bytes(32) + plaintext = b"Secret message" + associated_data = b"metadata" + + ciphertext = ENCRYPT(mk, plaintext, associated_data) + tampered = bytearray(ciphertext) + tampered[0] ^= 0xFF + tampered = bytes(tampered) + + with pytest.raises(ValueError): + DECRYPT(mk, tampered, associated_data) + + def test_encrypt_empty_message(self): + """Test encryption and decryption of empty message""" + mk = secrets.token_bytes(32) + plaintext = b"" + associated_data = b"metadata" + + ciphertext = ENCRYPT(mk, plaintext, associated_data) + decrypted = DECRYPT(mk, ciphertext, associated_data) + + assert decrypted == plaintext + + def test_encrypt_long_message(self): + """Test encryption and decryption of long message""" + mk = secrets.token_bytes(32) + plaintext = b"A" * 10000 + associated_data = b"metadata" + + ciphertext = ENCRYPT(mk, plaintext, associated_data) + decrypted = DECRYPT(mk, ciphertext, associated_data) + + assert decrypted == plaintext + + +class TestCONCAT: + """Test CONCAT function""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path = "db_keys/test_concat.db" + self.dh_keypair = x25519(keystore_path=self.keystore_path) + self.dh_keypair.init() + + def teardown_method(self): + """Cleanup test files""" + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) + + def test_concat_combines_ad_and_header(self): + """Test CONCAT combines associated data and header""" + ad = b"associated_data" + header = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) + + result = CONCAT(ad, header) + + assert result.startswith(ad) + assert len(result) == len(ad) + len(header.serialize()) + + def test_concat_deterministic(self): + """Test CONCAT is deterministic""" + ad = b"test_data" + header = HEADERS(dh_pair=self.dh_keypair, pn=3, n=7) + + result1 = CONCAT(ad, header) + result2 = CONCAT(ad, header) + + assert result1 == result2 + + def test_concat_empty_associated_data(self): + """Test CONCAT with empty associated data""" + ad = b"" + header = HEADERS(dh_pair=self.dh_keypair, pn=1, n=2) + + result = CONCAT(ad, header) + + assert result == header.serialize() + + +class TestIntegration: + """Integration tests combining multiple components""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path1 = "db_keys/test_int1.db" + self.keystore_path2 = "db_keys/test_int2.db" + + def teardown_method(self): + """Cleanup test files""" + for path in [self.keystore_path1, self.keystore_path2]: + if os.path.exists(path): + os.remove(path) + + def test_complete_message_exchange(self): + """Test complete message exchange between two parties""" + # Setup Alice's state + alice_state = States() + alice_state.DHs = GENERATE_DH(keystore_path=self.keystore_path1) + alice_state.RK = secrets.token_bytes(32) + alice_state.CKs = secrets.token_bytes(32) + alice_state.Ns = 0 + + # Setup Bob's initial DH + bob_dh = GENERATE_DH(keystore_path=self.keystore_path2) + alice_state.DHr = bob_dh.get_public_key() + + # Alice encrypts a message + plaintext = b"Hello Bob!" + header = HEADERS(dh_pair=alice_state.DHs, pn=alice_state.PN, n=alice_state.Ns) + ad = CONCAT(b"", header) + + new_ck, mk = KDF_CK(alice_state.CKs) + ciphertext = ENCRYPT(mk, plaintext, ad) + + # Verify Bob can decrypt + decrypted = DECRYPT(mk, ciphertext, ad) + assert decrypted == plaintext + + def test_state_persistence(self): + """Test state can be persisted and restored""" + # Create and populate state + state1 = States() + state1.DHs = GENERATE_DH(keystore_path=self.keystore_path1) + state1.RK = secrets.token_bytes(32) + state1.DHr = secrets.token_bytes(32) + state1.CKs = secrets.token_bytes(32) + state1.CKr = secrets.token_bytes(32) + state1.Ns = 10 + state1.Nr = 5 + state1.PN = 8 + state1.MKSKIPPED = {(b"key", 1): b"value"} + + # Serialize and deserialize + serialized = state1.serialize() + state2 = States.deserialize(serialized) + + # Verify all fields preserved + assert state2.Ns == 10 + assert state2.Nr == 5 + assert state2.PN == 8 + assert state2.MKSKIPPED == {(b"key", 1): b"value"} + assert state2 == state1 From 86da2348879fbe846dc5d3b644d0322f828fa625 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Wed, 3 Dec 2025 13:38:38 +0100 Subject: [PATCH 30/35] feat(protocols): add JSON serialization for States --- smswithoutborders_libsig/protocols.py | 90 +++++- tests/test_json_serialization.py | 428 ++++++++++++++++++++++++++ 2 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 tests/test_json_serialization.py diff --git a/smswithoutborders_libsig/protocols.py b/smswithoutborders_libsig/protocols.py index 1945548..33a2a2a 100755 --- a/smswithoutborders_libsig/protocols.py +++ b/smswithoutborders_libsig/protocols.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 +import base64 +import json import pickle import struct +import warnings from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA256, SHA512 @@ -29,11 +32,16 @@ class States: MKSKIPPED = {} def serialize(self) -> bytes: + warnings.warn( + "serialize() is deprecated due to pickle usage. Use serialize_json() instead.", + DeprecationWarning, + stacklevel=2, + ) if ( not hasattr(self, "DHs") - or self.DHs == None + or self.DHs is None or not hasattr(self, "RK") - or self.RK == None + or self.RK is None ): raise Exception( "State cannot be serialized: reason DHs == None or RK == None" @@ -42,10 +50,10 @@ def serialize(self) -> bytes: s_keypairs = self.DHs.serialize() s_keypairs_len = len(s_keypairs) - dhr_len = len(self.DHr) if not self.DHr is None else 0 - rk_len = len(self.RK) if not self.RK is None else 0 - ck_len = len(self.CKs) if not self.CKs is None else 0 - cr_len = len(self.CKr) if not self.CKr is None else 0 + dhr_len = len(self.DHr) if self.DHr is not None else 0 + rk_len = len(self.RK) if self.RK is not None else 0 + ck_len = len(self.CKs) if self.CKs is not None else 0 + cr_len = len(self.CKr) if self.CKr is not None else 0 len_start = struct.pack( f"<{'i' * 5}", s_keypairs_len, rk_len, dhr_len, ck_len, cr_len @@ -63,6 +71,11 @@ def serialize(self) -> bytes: @staticmethod def deserialize(data): + warnings.warn( + "deserialize() is deprecated due to pickle usage. Use deserialize_json() instead.", + DeprecationWarning, + stacklevel=2, + ) state = States() s_keypairs_len, dhr_len, rk_len, cks_len, ckr_len = struct.unpack( @@ -104,6 +117,71 @@ def deserialize(data): return state + def serialize_json(self) -> bytes: + """ + Serialize state to JSON format + Returns bytes containing JSON-encoded state. + """ + if ( + not hasattr(self, "DHs") + or self.DHs is None + or not hasattr(self, "RK") + or self.RK is None + ): + raise Exception( + "State cannot be serialized: reason DHs == None or RK == None" + ) + + mkskipped_encoded = {} + for (dh_key, n), mk_value in self.MKSKIPPED.items(): + key_str = f"{base64.b64encode(dh_key).decode('ascii')}:{n}" + mkskipped_encoded[key_str] = base64.b64encode(mk_value).decode("ascii") + + state_dict = { + "version": 1, + "DHs": base64.b64encode(self.DHs.serialize()).decode("ascii"), + "DHr": base64.b64encode(self.DHr).decode("ascii") if self.DHr else None, + "RK": base64.b64encode(self.RK).decode("ascii"), + "CKs": base64.b64encode(self.CKs).decode("ascii") if self.CKs else None, + "CKr": base64.b64encode(self.CKr).decode("ascii") if self.CKr else None, + "Ns": self.Ns, + "Nr": self.Nr, + "PN": self.PN, + "MKSKIPPED": mkskipped_encoded, + } + + return json.dumps(state_dict).encode("utf-8") + + @staticmethod + def deserialize_json(data: bytes): + """ + Deserialize state from JSON format. + """ + state = States() + state_dict = json.loads(data.decode("utf-8")) + + if state_dict.get("version") != 1: + raise ValueError(f"Unsupported state version: {state_dict.get('version')}") + + state.DHs = x25519().deserialize(base64.b64decode(state_dict["DHs"])) + state.RK = base64.b64decode(state_dict["RK"]) + state.DHr = base64.b64decode(state_dict["DHr"]) if state_dict["DHr"] else None + state.CKs = base64.b64decode(state_dict["CKs"]) if state_dict["CKs"] else None + state.CKr = base64.b64decode(state_dict["CKr"]) if state_dict["CKr"] else None + state.Ns = state_dict["Ns"] + state.Nr = state_dict["Nr"] + state.PN = state_dict["PN"] + + state.MKSKIPPED = {} + for key_str, mk_value_encoded in state_dict["MKSKIPPED"].items(): + dh_b64, n_str = key_str.rsplit(":", 1) + dh_key = base64.b64decode(dh_b64) + n = int(n_str) + mk_value = base64.b64decode(mk_value_encoded) + state.MKSKIPPED[(dh_key, n)] = mk_value + + return state + def __eq__(self, other): if not isinstance(other, States): return NotImplemented diff --git a/tests/test_json_serialization.py b/tests/test_json_serialization.py new file mode 100644 index 0000000..78b470c --- /dev/null +++ b/tests/test_json_serialization.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 + +import os +import secrets +import pytest +from smswithoutborders_libsig.protocols import States +from smswithoutborders_libsig.keypairs import x25519 + + +class TestJSONSerialization: + """Test JSON-based serialization as a safer alternative to pickle""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path = "db_keys/test_json_states.db" + self.dh_keypair = x25519(keystore_path=self.keystore_path) + self.dh_keypair.init() + + def teardown_method(self): + """Cleanup test files""" + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) + + def test_json_serialization_basic(self): + """Test basic JSON serialization""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = secrets.token_bytes(32) + state.CKr = secrets.token_bytes(32) + state.Ns = 5 + state.Nr = 3 + state.PN = 2 + + serialized = state.serialize_json() + assert isinstance(serialized, bytes) + assert len(serialized) > 0 + + def test_json_deserialization_basic(self): + """Test basic JSON deserialization""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = secrets.token_bytes(32) + state.CKr = secrets.token_bytes(32) + state.Ns = 5 + state.Nr = 3 + state.PN = 2 + + serialized = state.serialize_json() + deserialized = States.deserialize_json(serialized) + + assert deserialized == state + assert deserialized.Ns == 5 + assert deserialized.Nr == 3 + assert deserialized.PN == 2 + + def test_json_serialization_with_mkskipped(self): + """Test JSON serialization with MKSKIPPED data""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = secrets.token_bytes(32) + state.CKr = secrets.token_bytes(32) + state.Ns = 10 + state.Nr = 7 + state.PN = 5 + state.MKSKIPPED = { + (b"dh_key_1" * 4, 1): b"message_key_1" * 2, + (b"dh_key_2" * 4, 5): b"message_key_2" * 2, + (b"dh_key_3" * 4, 10): b"message_key_3" * 2, + } + + serialized = state.serialize_json() + deserialized = States.deserialize_json(serialized) + + assert deserialized == state + assert deserialized.MKSKIPPED == state.MKSKIPPED + assert len(deserialized.MKSKIPPED) == 3 + + def test_json_serialization_with_none_values(self): + """Test JSON serialization handles None values correctly""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = None + state.CKr = None + + serialized = state.serialize_json() + deserialized = States.deserialize_json(serialized) + + assert deserialized.DHr == state.DHr + assert deserialized.CKs is None + assert deserialized.CKr is None + assert deserialized == state + + def test_json_serialization_empty_mkskipped(self): + """Test JSON serialization with empty MKSKIPPED""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.MKSKIPPED = {} + + serialized = state.serialize_json() + deserialized = States.deserialize_json(serialized) + + assert deserialized.MKSKIPPED == {} + assert deserialized == state + + def test_json_serialization_without_required_fields(self): + """Test JSON serialization fails without DHs or RK""" + state = States() + with pytest.raises(Exception, match="State cannot be serialized"): + state.serialize_json() + + state.DHs = self.dh_keypair + with pytest.raises(Exception, match="State cannot be serialized"): + state.serialize_json() + + def test_json_serialization_deterministic(self): + """Test JSON serialization is deterministic""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.Ns = 5 + + serialized1 = state.serialize_json() + serialized2 = state.serialize_json() + + assert serialized1 == serialized2 + + def test_json_output_is_valid_json(self): + """Test that serialized output is valid JSON""" + import json + + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + + serialized = state.serialize_json() + + # Should not raise exception + parsed = json.loads(serialized.decode("utf-8")) + assert isinstance(parsed, dict) + assert "version" in parsed + assert parsed["version"] == 1 + + def test_json_no_binary_in_output(self): + """Test that JSON output contains no binary data (only base64 strings)""" + import json + + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.MKSKIPPED = {(b"test_key" * 4, 1): b"test_value" * 2} + + serialized = state.serialize_json() + parsed = json.loads(serialized.decode("utf-8")) + + # All values should be strings, integers, or dicts (no bytes) + assert isinstance(parsed["DHs"], str) + assert isinstance(parsed["RK"], str) + assert isinstance(parsed["DHr"], str) + assert isinstance(parsed["Ns"], int) + assert isinstance(parsed["MKSKIPPED"], dict) + for key, value in parsed["MKSKIPPED"].items(): + assert isinstance(key, str) + assert isinstance(value, str) + + +class TestPickleToJSONMigration: + """Test migration from pickle serialization to JSON serialization""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path = "db_keys/test_migration.db" + self.dh_keypair = x25519(keystore_path=self.keystore_path) + self.dh_keypair.init() + + def teardown_method(self): + """Cleanup test files""" + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) + + def test_migrate_pickle_to_json_basic(self): + """Test migrating basic state from pickle to JSON""" + # Create state and serialize with pickle + state_original = States() + state_original.DHs = self.dh_keypair + state_original.RK = secrets.token_bytes(32) + state_original.DHr = secrets.token_bytes(32) + state_original.CKs = secrets.token_bytes(32) + state_original.CKr = secrets.token_bytes(32) + state_original.Ns = 5 + state_original.Nr = 3 + state_original.PN = 2 + + pickle_serialized = state_original.serialize() + + # Deserialize with pickle + state_from_pickle = States.deserialize(pickle_serialized) + + # Re-serialize with JSON + json_serialized = state_from_pickle.serialize_json() + + # Deserialize with JSON + state_from_json = States.deserialize_json(json_serialized) + + # Verify all data is preserved + assert state_from_json == state_original + assert state_from_json.Ns == 5 + assert state_from_json.Nr == 3 + assert state_from_json.PN == 2 + + def test_migrate_pickle_to_json_with_mkskipped(self): + """Test migrating state with MKSKIPPED from pickle to JSON""" + # Create state with MKSKIPPED + state_original = States() + state_original.DHs = self.dh_keypair + state_original.RK = secrets.token_bytes(32) + state_original.DHr = secrets.token_bytes(32) + state_original.CKs = secrets.token_bytes(32) + state_original.CKr = secrets.token_bytes(32) + state_original.Ns = 10 + state_original.Nr = 7 + state_original.PN = 5 + state_original.MKSKIPPED = { + (b"dh_key_1" * 4, 1): b"message_key_1" * 2, + (b"dh_key_2" * 4, 5): b"message_key_2" * 2, + (b"dh_key_3" * 4, 10): b"message_key_3" * 2, + } + + # Pickle serialize + pickle_serialized = state_original.serialize() + + # Deserialize with pickle + state_from_pickle = States.deserialize(pickle_serialized) + + # Re-serialize with JSON + json_serialized = state_from_pickle.serialize_json() + + # Deserialize with JSON + state_from_json = States.deserialize_json(json_serialized) + + # Verify all data is preserved including MKSKIPPED + assert state_from_json == state_original + assert state_from_json.MKSKIPPED == state_original.MKSKIPPED + assert len(state_from_json.MKSKIPPED) == 3 + + # Verify each MKSKIPPED entry + for key, value in state_original.MKSKIPPED.items(): + assert key in state_from_json.MKSKIPPED + assert state_from_json.MKSKIPPED[key] == value + + def test_migrate_pickle_to_json_with_none_values(self): + """Test migrating state with None values from pickle to JSON""" + state_original = States() + state_original.DHs = self.dh_keypair + state_original.RK = secrets.token_bytes(32) + state_original.DHr = secrets.token_bytes(32) + state_original.CKs = None + state_original.CKr = None + + pickle_serialized = state_original.serialize() + state_from_pickle = States.deserialize(pickle_serialized) + json_serialized = state_from_pickle.serialize_json() + state_from_json = States.deserialize_json(json_serialized) + + assert state_from_json == state_original + assert state_from_json.CKs is None + assert state_from_json.CKr is None + + def test_migrate_pickle_to_json_preserves_crypto_keys(self): + """Test that cryptographic keys are preserved during migration""" + state_original = States() + state_original.DHs = self.dh_keypair + state_original.RK = secrets.token_bytes(32) + state_original.DHr = secrets.token_bytes(32) + state_original.CKs = secrets.token_bytes(32) + state_original.CKr = secrets.token_bytes(32) + + pickle_serialized = state_original.serialize() + state_from_pickle = States.deserialize(pickle_serialized) + json_serialized = state_from_pickle.serialize_json() + state_from_json = States.deserialize_json(json_serialized) + + # Verify cryptographic keys are byte-for-byte identical + assert state_from_json.RK == state_original.RK + assert state_from_json.DHr == state_original.DHr + assert state_from_json.CKs == state_original.CKs + assert state_from_json.CKr == state_original.CKr + assert ( + state_from_json.DHs.get_public_key() == state_original.DHs.get_public_key() + ) + + def test_migrate_multiple_states(self): + """Test migrating multiple different states from pickle to JSON""" + states = [] + + # Create 5 different states + for i in range(5): + keystore_path = f"db_keys/test_migration_{i}.db" + dh = x25519(keystore_path=keystore_path) + dh.init() + + state = States() + state.DHs = dh + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + state.CKs = secrets.token_bytes(32) + state.CKr = secrets.token_bytes(32) + state.Ns = i * 10 + state.Nr = i * 5 + state.PN = i * 3 + state.MKSKIPPED = { + (secrets.token_bytes(32), j): secrets.token_bytes(32) for j in range(i) + } + + states.append((state, keystore_path)) + + # Migrate each state + for original_state, keystore_path in states: + pickle_serialized = original_state.serialize() + state_from_pickle = States.deserialize(pickle_serialized) + json_serialized = state_from_pickle.serialize_json() + state_from_json = States.deserialize_json(json_serialized) + + assert state_from_json == original_state + + # Cleanup + if os.path.exists(keystore_path): + os.remove(keystore_path) + + def test_json_deserialization_invalid_version(self): + """Test that invalid version raises an error""" + import json + + invalid_data = json.dumps({"version": 99}).encode("utf-8") + + with pytest.raises(ValueError, match="Unsupported state version"): + States.deserialize_json(invalid_data) + + def test_roundtrip_consistency_pickle_vs_json(self): + """Test that pickle and JSON produce equivalent results after roundtrip""" + state_original = States() + state_original.DHs = self.dh_keypair + state_original.RK = secrets.token_bytes(32) + state_original.DHr = secrets.token_bytes(32) + state_original.CKs = secrets.token_bytes(32) + state_original.CKr = secrets.token_bytes(32) + state_original.Ns = 42 + state_original.Nr = 24 + state_original.PN = 12 + state_original.MKSKIPPED = {(b"key" * 8, 7): b"value" * 8} + + # Roundtrip through pickle + pickle_roundtrip = States.deserialize(state_original.serialize()) + + # Roundtrip through JSON + json_roundtrip = States.deserialize_json(state_original.serialize_json()) + + # Both should equal the original + assert pickle_roundtrip == state_original + assert json_roundtrip == state_original + + # And should equal each other + assert pickle_roundtrip == json_roundtrip + + +class TestJSONSecurityProperties: + """Test security properties of JSON serialization""" + + def setup_method(self): + """Setup test fixtures""" + self.keystore_path = "db_keys/test_security.db" + self.dh_keypair = x25519(keystore_path=self.keystore_path) + self.dh_keypair.init() + + def teardown_method(self): + """Cleanup test files""" + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) + + def test_json_no_code_execution_risk(self): + """Test that JSON deserialization does not execute code""" + # JSON should not allow code execution unlike pickle + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + + serialized = state.serialize_json() + + # This should safely deserialize without any code execution + deserialized = States.deserialize_json(serialized) + assert deserialized == state + + def test_json_malformed_input_handling(self): + """Test that malformed JSON input raises appropriate errors""" + with pytest.raises((ValueError, Exception)): + States.deserialize_json(b"not valid json") + + with pytest.raises((ValueError, Exception)): + States.deserialize_json(b"{incomplete") + + def test_json_tampering_detection(self): + """Test that tampering with JSON is detectable through data validation""" + state = States() + state.DHs = self.dh_keypair + state.RK = secrets.token_bytes(32) + state.DHr = secrets.token_bytes(32) + + serialized = state.serialize_json() + + # Tamper with the data + tampered = serialized.replace(b'"version": 1', b'"version": "hacked"') + + # Should fail validation + with pytest.raises((ValueError, TypeError, Exception)): + States.deserialize_json(tampered) From 7daae75dddacf652bdf9370062d857ddc7dc4a26 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Wed, 3 Dec 2025 13:53:49 +0100 Subject: [PATCH 31/35] refactor: remove ecdsa dependency and ECDH keypair class --- requirements.txt | 1 - smswithoutborders_libsig/keypairs.py | 42 ---------------------------- 2 files changed, 43 deletions(-) diff --git a/requirements.txt b/requirements.txt index a630a96..39fd88d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ cffi==2.0.0 cryptography==46.0.3 -ecdsa==0.19.1 pycparser==2.23 pycryptodome==3.23.0 six==1.17.0 diff --git a/smswithoutborders_libsig/keypairs.py b/smswithoutborders_libsig/keypairs.py index aa8ed63..9657163 100755 --- a/smswithoutborders_libsig/keypairs.py +++ b/smswithoutborders_libsig/keypairs.py @@ -12,7 +12,6 @@ X25519PublicKey, ) from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from ecdsa import ECDH, NIST256p from smswithoutborders_libsig.keystore import Keystore @@ -81,47 +80,6 @@ def __agree__(secret_key, info=b"x25591_key_exchange", salt=None): ).derive(secret_key) -class ecdh(Keypairs): - def __init__(self, pnt_keystore=None, keystore_path=None, secret_key=None): - self.pnt_keystore = pnt_keystore - self.keystore_path = keystore_path - - def init(self): - ecdh = ECDH(curve=NIST256p) - pk = ecdh.generate_private_key() - self.pnt_keystore = uuid.uuid4().hex - - if not self.keystore_path: - self.keystore_path = f"db_keys/{self.pnt_keystore}.db" - - self.secret_key = Keypairs.store( - pk.to_string(), - ecdh.private_key.to_string(), - self.keystore_path, - self.pnt_keystore, - ) - return pk.to_string() - - def get_public_key(self): - ppk = Keypairs.fetch(self.pnt_keystore, self.secret_key, self.keystore_path) - return ppk[0] - - def load_keystore(self): - pass - - def agree(self, public_key, info=b"x25591_key_exchange", salt=None) -> bytes: - if not self.keystore_path: - self.keystore_path = f"db_keys/{pnt_keystore}.db" - ppk = Keypairs.fetch(self.pnt_keystore, self.secret_key, self.keystore_path) - if ppk: - ecdh = ECDH(curve=NIST256p) - ecdh.load_private_key_bytes(ppk[1]) - # ecdh.load_received_public_key_pem(public_key) - ecdh.load_received_public_key_bytes(public_key) - shared_key = ecdh.generate_sharedsecret_bytes() - return Keypairs.__agree__(shared_key, info, salt) - - class x25519(Keypairs): def __init__(self, keystore_path=None, pnt_keystore=None, secret_key=None): self.keystore_path = keystore_path From be4ec8a9295465804c4ee4fb68bc82f6103c51b8 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 11 Dec 2025 13:57:36 +0100 Subject: [PATCH 32/35] Bump version from 0.1.11 to 0.2.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 20f4951..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.11 +0.2.0 From 896860d3b8d996adf2ed8aab9670a2ef42db2e8d Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Mon, 15 Dec 2025 17:12:51 +0100 Subject: [PATCH 33/35] refactor(protocols): remove __eq__ methods from core classes --- smswithoutborders_libsig/keypairs.py | 34 +-- smswithoutborders_libsig/protocols.py | 37 --- tests/test_helpers.py | 66 +++++ tests/test_json_serialization.py | 39 +-- tests/test_protocols.py | 409 +++++--------------------- tests/test_x25519_keypairs.py | 67 +---- 6 files changed, 176 insertions(+), 476 deletions(-) create mode 100644 tests/test_helpers.py diff --git a/smswithoutborders_libsig/keypairs.py b/smswithoutborders_libsig/keypairs.py index 9657163..4d75d4c 100755 --- a/smswithoutborders_libsig/keypairs.py +++ b/smswithoutborders_libsig/keypairs.py @@ -6,7 +6,7 @@ import uuid from abc import ABC, abstractmethod -from cryptography.hazmat.primitives import constant_time, hashes +from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, @@ -139,18 +139,6 @@ def deserialize(self, data) -> Keypairs: x.secret_key = data[(8 + keystore_path_len + pnt_keystore_len) :].decode() return x - def __eq__(self, other): - if not isinstance(other, Keypairs): - return NotImplemented - - return ( - other.keystore_path == self.keystore_path - and other.pnt_keystore == self.pnt_keystore - and constant_time.bytes_eq( - other.secret_key.encode(), self.secret_key.encode() - ) - ) - def load_keystore(self, pnt_keystore: str, secret_key: bytes): if not self.keystore_path: self.keystore_path = f"db_keys/{pnt_keystore}.db" @@ -170,23 +158,3 @@ def agree(self, public_key, info=b"x25591_key_exchange", salt=None) -> bytes: x = self.load_keystore(self.pnt_keystore, self.secret_key) shared_key = x.exchange(X25519PublicKey.from_public_bytes(public_key)) return Keypairs.__agree__(shared_key, info, salt) - - -if __name__ == "__main__": - client1 = x25519() - client1_public_key = client1.init() - - client2 = x25519() - client2_public_key = client2.init() - - dk = client1.agree(client2_public_key) - dk1 = client2.agree(client1_public_key) - - assert dk != None - assert dk1 != None - assert dk == dk1 - - s_c1 = client1.serialize() - d_c1 = client1.deserialize(s_c1) - - assert d_c1 == client1 diff --git a/smswithoutborders_libsig/protocols.py b/smswithoutborders_libsig/protocols.py index 33a2a2a..ad0717a 100755 --- a/smswithoutborders_libsig/protocols.py +++ b/smswithoutborders_libsig/protocols.py @@ -10,7 +10,6 @@ from Crypto.Hash import HMAC, SHA256, SHA512 from Crypto.Protocol.KDF import HKDF from Crypto.Util.Padding import pad, unpad -from cryptography.hazmat.primitives import constant_time import smswithoutborders_libsig.helpers as helpers from smswithoutborders_libsig.keypairs import Keypairs, x25519 @@ -182,35 +181,6 @@ def deserialize_json(data: bytes): return state - def __eq__(self, other): - if not isinstance(other, States): - return NotImplemented - - dhr_equal = constant_time.bytes_eq( - self.DHr if self.DHr else b"", other.DHr if other.DHr else b"" - ) - rk_equal = constant_time.bytes_eq( - self.RK if self.RK else b"", other.RK if other.RK else b"" - ) - cks_equal = constant_time.bytes_eq( - self.CKs if self.CKs else b"", other.CKs if other.CKs else b"" - ) - ckr_equal = constant_time.bytes_eq( - self.CKr if self.CKr else b"", other.CKr if other.CKr else b"" - ) - - return ( - self.DHs == other.DHs - and dhr_equal - and rk_equal - and cks_equal - and ckr_equal - and self.Ns == other.Ns - and self.Nr == other.Nr - and self.PN == other.PN - and self.MKSKIPPED == other.MKSKIPPED - ) - class HEADERS: dh: bytes # public key bytes @@ -242,13 +212,6 @@ def deserialize(data): return headers - def __eq__(self, other): - return ( - constant_time.bytes_eq(self.dh, other.dh) - and self.pn == other.pn - and self.n == other.n - ) - class DHRatchet: def __init__(self, state: States, header: HEADERS): diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..77676b6 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,66 @@ +"""Test utilities.""" + +from cryptography.hazmat.primitives import constant_time + +from smswithoutborders_libsig.keypairs import Keypairs +from smswithoutborders_libsig.protocols import HEADERS, States + + +def states_equal(state1, state2) -> bool: + """Compare two States objects.""" + + if not isinstance(state1, States) or not isinstance(state2, States): + return False + + dhr_equal = constant_time.bytes_eq( + state1.DHr if state1.DHr else b"", state2.DHr if state2.DHr else b"" + ) + rk_equal = constant_time.bytes_eq( + state1.RK if state1.RK else b"", state2.RK if state2.RK else b"" + ) + cks_equal = constant_time.bytes_eq( + state1.CKs if state1.CKs else b"", state2.CKs if state2.CKs else b"" + ) + ckr_equal = constant_time.bytes_eq( + state1.CKr if state1.CKr else b"", state2.CKr if state2.CKr else b"" + ) + + return ( + keypairs_equal(state1.DHs, state2.DHs) + and dhr_equal + and rk_equal + and cks_equal + and ckr_equal + and state1.Ns == state2.Ns + and state1.Nr == state2.Nr + and state1.PN == state2.PN + and state1.MKSKIPPED == state2.MKSKIPPED + ) + + +def headers_equal(header1, header2) -> bool: + """Compare two HEADERS objects.""" + + if not isinstance(header1, HEADERS) or not isinstance(header2, HEADERS): + return False + + return ( + constant_time.bytes_eq(header1.dh, header2.dh) + and header1.pn == header2.pn + and header1.n == header2.n + ) + + +def keypairs_equal(keypair1, keypair2) -> bool: + """Compare two Keypairs objects.""" + + if not isinstance(keypair1, Keypairs) or not isinstance(keypair2, Keypairs): + return False + + return ( + keypair1.keystore_path == keypair2.keystore_path + and keypair1.pnt_keystore == keypair2.pnt_keystore + and constant_time.bytes_eq( + keypair1.secret_key.encode(), keypair2.secret_key.encode() + ) + ) diff --git a/tests/test_json_serialization.py b/tests/test_json_serialization.py index 78b470c..e21890e 100644 --- a/tests/test_json_serialization.py +++ b/tests/test_json_serialization.py @@ -1,14 +1,15 @@ -#!/usr/bin/env python3 - import os import secrets + import pytest -from smswithoutborders_libsig.protocols import States + from smswithoutborders_libsig.keypairs import x25519 +from smswithoutborders_libsig.protocols import States +from tests.test_helpers import states_equal class TestJSONSerialization: - """Test JSON-based serialization as a safer alternative to pickle""" + """Test JSON-based serialization.""" def setup_method(self): """Setup test fixtures""" @@ -52,7 +53,7 @@ def test_json_deserialization_basic(self): serialized = state.serialize_json() deserialized = States.deserialize_json(serialized) - assert deserialized == state + assert states_equal(deserialized, state) assert deserialized.Ns == 5 assert deserialized.Nr == 3 assert deserialized.PN == 2 @@ -77,7 +78,7 @@ def test_json_serialization_with_mkskipped(self): serialized = state.serialize_json() deserialized = States.deserialize_json(serialized) - assert deserialized == state + assert states_equal(deserialized, state) assert deserialized.MKSKIPPED == state.MKSKIPPED assert len(deserialized.MKSKIPPED) == 3 @@ -96,7 +97,7 @@ def test_json_serialization_with_none_values(self): assert deserialized.DHr == state.DHr assert deserialized.CKs is None assert deserialized.CKr is None - assert deserialized == state + assert states_equal(deserialized, state) def test_json_serialization_empty_mkskipped(self): """Test JSON serialization with empty MKSKIPPED""" @@ -110,7 +111,7 @@ def test_json_serialization_empty_mkskipped(self): deserialized = States.deserialize_json(serialized) assert deserialized.MKSKIPPED == {} - assert deserialized == state + assert states_equal(deserialized, state) def test_json_serialization_without_required_fields(self): """Test JSON serialization fails without DHs or RK""" @@ -215,7 +216,7 @@ def test_migrate_pickle_to_json_basic(self): state_from_json = States.deserialize_json(json_serialized) # Verify all data is preserved - assert state_from_json == state_original + assert states_equal(state_from_json, state_original) assert state_from_json.Ns == 5 assert state_from_json.Nr == 3 assert state_from_json.PN == 2 @@ -251,7 +252,7 @@ def test_migrate_pickle_to_json_with_mkskipped(self): state_from_json = States.deserialize_json(json_serialized) # Verify all data is preserved including MKSKIPPED - assert state_from_json == state_original + assert states_equal(state_from_json, state_original) assert state_from_json.MKSKIPPED == state_original.MKSKIPPED assert len(state_from_json.MKSKIPPED) == 3 @@ -274,7 +275,7 @@ def test_migrate_pickle_to_json_with_none_values(self): json_serialized = state_from_pickle.serialize_json() state_from_json = States.deserialize_json(json_serialized) - assert state_from_json == state_original + assert states_equal(state_from_json, state_original) assert state_from_json.CKs is None assert state_from_json.CKr is None @@ -305,9 +306,11 @@ def test_migrate_multiple_states(self): """Test migrating multiple different states from pickle to JSON""" states = [] - # Create 5 different states for i in range(5): keystore_path = f"db_keys/test_migration_{i}.db" + if os.path.exists(keystore_path): + os.remove(keystore_path) + dh = x25519(keystore_path=keystore_path) dh.init() @@ -326,16 +329,14 @@ def test_migrate_multiple_states(self): states.append((state, keystore_path)) - # Migrate each state for original_state, keystore_path in states: pickle_serialized = original_state.serialize() state_from_pickle = States.deserialize(pickle_serialized) json_serialized = state_from_pickle.serialize_json() state_from_json = States.deserialize_json(json_serialized) - assert state_from_json == original_state + assert states_equal(state_from_json, original_state) - # Cleanup if os.path.exists(keystore_path): os.remove(keystore_path) @@ -368,11 +369,11 @@ def test_roundtrip_consistency_pickle_vs_json(self): json_roundtrip = States.deserialize_json(state_original.serialize_json()) # Both should equal the original - assert pickle_roundtrip == state_original - assert json_roundtrip == state_original + assert states_equal(pickle_roundtrip, state_original) + assert states_equal(json_roundtrip, state_original) # And should equal each other - assert pickle_roundtrip == json_roundtrip + assert states_equal(pickle_roundtrip, json_roundtrip) class TestJSONSecurityProperties: @@ -401,7 +402,7 @@ def test_json_no_code_execution_risk(self): # This should safely deserialize without any code execution deserialized = States.deserialize_json(serialized) - assert deserialized == state + assert states_equal(deserialized, state) def test_json_malformed_input_handling(self): """Test that malformed JSON input raises appropriate errors""" diff --git a/tests/test_protocols.py b/tests/test_protocols.py index 87dae56..d3ed0ea 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -1,78 +1,48 @@ -#!/usr/bin/env python3 +"""Tests for protocol components.""" import os import secrets + import pytest + +from smswithoutborders_libsig.keypairs import x25519 from smswithoutborders_libsig.protocols import ( - States, - HEADERS, - DHRatchet, - GENERATE_DH, + CONCAT, + DECRYPT, DH, - KDF_RK, - KDF_CK, ENCRYPT, - DECRYPT, - CONCAT, + GENERATE_DH, + HEADERS, + KDF_CK, + KDF_RK, + DHRatchet, + States, ) -from smswithoutborders_libsig.keypairs import x25519 +from tests.test_helpers import headers_equal, states_equal class TestStates: - """Test States class: serialization, deserialization, and equality""" + """Test States serialization and deserialization.""" def setup_method(self): - """Setup test fixtures""" self.keystore_path = "db_keys/test_states.db" self.dh_keypair = x25519(keystore_path=self.keystore_path) self.dh_keypair.init() def teardown_method(self): - """Cleanup test files""" if os.path.exists(self.keystore_path): os.remove(self.keystore_path) def test_state_initialization(self): - """Test States can be initialized with default values""" + """Test States initializes with default values.""" state = States() assert state.DHs is None - assert state.DHr is None assert state.RK is None - assert state.CKs is None - assert state.CKr is None assert state.Ns == 0 - assert state.Nr == 0 - assert state.PN == 0 assert state.MKSKIPPED == {} - def test_state_serialization(self): - """Test States can be serialized with required fields""" - state = States() - state.DHs = self.dh_keypair - state.RK = secrets.token_bytes(32) - state.DHr = secrets.token_bytes(32) - state.CKs = secrets.token_bytes(32) - state.CKr = secrets.token_bytes(32) - state.Ns = 5 - state.Nr = 3 - state.PN = 2 - - serialized = state.serialize() - assert isinstance(serialized, bytes) - assert len(serialized) > 0 - - def test_state_serialization_without_required_fields(self): - """Test serialization fails without DHs or RK""" - state = States() - with pytest.raises(Exception, match="State cannot be serialized"): - state.serialize() - - state.DHs = self.dh_keypair - with pytest.raises(Exception, match="State cannot be serialized"): - state.serialize() - - def test_state_deserialization(self): - """Test States can be deserialized correctly""" + def test_state_serialization_roundtrip(self): + """Test States serialization and deserialization.""" state = States() state.DHs = self.dh_keypair state.RK = secrets.token_bytes(32) @@ -87,14 +57,18 @@ def test_state_deserialization(self): serialized = state.serialize() deserialized = States.deserialize(serialized) - assert deserialized == state + assert states_equal(deserialized, state) assert deserialized.Ns == 5 - assert deserialized.Nr == 3 - assert deserialized.PN == 2 assert deserialized.MKSKIPPED == {(b"key1", 1): b"value1"} + def test_state_serialization_requires_fields(self): + """Test serialization requires DHs and RK.""" + state = States() + with pytest.raises(Exception, match="State cannot be serialized"): + state.serialize() + def test_state_serialization_with_none_values(self): - """Test States serialization handles None values""" + """Test States handles None values.""" state = States() state.DHs = self.dh_keypair state.RK = secrets.token_bytes(32) @@ -105,26 +79,11 @@ def test_state_serialization_with_none_values(self): serialized = state.serialize() deserialized = States.deserialize(serialized) - assert deserialized.DHr == state.DHr assert deserialized.CKs is None assert deserialized.CKr is None - def test_state_equality_identical_states(self): - """Test two identical states are equal""" - state1 = States() - state1.DHs = self.dh_keypair - state1.RK = secrets.token_bytes(32) - state1.DHr = secrets.token_bytes(32) - state1.CKs = secrets.token_bytes(32) - state1.CKr = secrets.token_bytes(32) - - serialized = state1.serialize() - state2 = States.deserialize(serialized) - - assert state1 == state2 - - def test_state_equality_different_states(self): - """Test different states are not equal""" + def test_states_equality(self): + """Test states comparison.""" state1 = States() state1.DHs = self.dh_keypair state1.RK = secrets.token_bytes(32) @@ -135,131 +94,53 @@ def test_state_equality_different_states(self): state2.RK = secrets.token_bytes(32) state2.Ns = 2 - assert state1 != state2 - - def test_state_equality_constant_time_comparison(self): - """Test state equality uses constant-time comparison for secrets""" - state1 = States() - state1.DHs = self.dh_keypair - state1.RK = b"secret_key_1" * 3 - state1.DHr = b"dh_remote_1" * 3 - - state2 = States() - state2.DHs = self.dh_keypair - state2.RK = b"secret_key_2" * 3 - state2.DHr = b"dh_remote_1" * 3 - - assert state1 != state2 - - def test_state_equality_with_non_state_object(self): - """Test equality with non-States object returns NotImplemented""" - state = States() - state.DHs = self.dh_keypair - state.RK = secrets.token_bytes(32) - - assert state.__eq__("not a state") == NotImplemented - assert state.__eq__(42) == NotImplemented + assert not states_equal(state1, state2) class TestHEADERS: - """Test HEADERS class: initialization, serialization, and equality""" + """Test HEADERS serialization.""" def setup_method(self): - """Setup test fixtures""" self.keystore_path = "db_keys/test_headers.db" self.dh_keypair = x25519(keystore_path=self.keystore_path) self.dh_keypair.init() def teardown_method(self): - """Cleanup test files""" if os.path.exists(self.keystore_path): os.remove(self.keystore_path) - def test_header_initialization_with_keypair(self): - """Test HEADERS initialization with DH keypair""" - header = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) - assert header.dh == self.dh_keypair.get_public_key() - assert header.pn == 5 - assert header.n == 10 - - def test_header_initialization_without_keypair(self): - """Test HEADERS initialization without DH keypair""" - header = HEADERS(pn=3, n=7) - assert header.pn == 3 - assert header.n == 7 - assert not hasattr(header, "dh") or header.dh is None - - def test_header_serialization(self): - """Test HEADERS can be serialized""" - header = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) - serialized = header.serialize() - - assert isinstance(serialized, bytes) - assert len(serialized) > 8 # 8 bytes for pn + n, plus dh key - - def test_header_deserialization(self): - """Test HEADERS can be deserialized correctly""" + def test_header_serialization_roundtrip(self): + """Test HEADERS serialization and deserialization.""" header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) serialized = header1.serialize() header2 = HEADERS.deserialize(serialized) - assert header1 == header2 + assert headers_equal(header1, header2) assert header2.pn == 5 assert header2.n == 10 - assert header2.dh == self.dh_keypair.get_public_key() - def test_header_equality_identical_headers(self): - """Test two identical headers are equal""" - header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) - serialized = header1.serialize() - header2 = HEADERS.deserialize(serialized) - - assert header1 == header2 - - def test_header_equality_different_headers(self): - """Test different headers are not equal""" + def test_headers_equality(self): + """Test headers comparison.""" header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) header2 = HEADERS(dh_pair=self.dh_keypair, pn=6, n=10) - assert header1 != header2 - - def test_header_equality_constant_time_comparison(self): - """Test header equality uses constant-time comparison for dh""" - keypair2 = x25519(keystore_path="db_keys/test_headers2.db") - keypair2.init() - - header1 = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) - header2 = HEADERS(dh_pair=keypair2, pn=5, n=10) - - assert header1 != header2 - - if os.path.exists("db_keys/test_headers2.db"): - os.remove("db_keys/test_headers2.db") + assert not headers_equal(header1, header2) class TestDHRatchet: - """Test DHRatchet class and DH operations""" + """Test DH operations.""" def setup_method(self): - """Setup test fixtures""" self.keystore_path1 = "db_keys/test_dh1.db" self.keystore_path2 = "db_keys/test_dh2.db" def teardown_method(self): - """Cleanup test files""" for path in [self.keystore_path1, self.keystore_path2]: if os.path.exists(path): os.remove(path) - def test_generate_dh(self): - """Test DH keypair generation""" - dh = GENERATE_DH(keystore_path=self.keystore_path1) - assert dh is not None - assert hasattr(dh, "get_public_key") - assert len(dh.get_public_key()) == 32 - def test_dh_agreement(self): - """Test DH key agreement between two parties""" + """Test DH key agreement.""" dh1 = GENERATE_DH(keystore_path=self.keystore_path1) dh2 = GENERATE_DH(keystore_path=self.keystore_path2) @@ -270,45 +151,28 @@ def test_dh_agreement(self): assert len(shared1) == 32 def test_dh_ratchet_updates_state(self): - """Test DHRatchet updates state correctly""" + """Test DHRatchet updates state.""" state = States() state.DHs = GENERATE_DH(keystore_path=self.keystore_path1) state.RK = secrets.token_bytes(32) state.Ns = 5 - state.Nr = 3 - state.PN = 2 dh_remote = GENERATE_DH(keystore_path=self.keystore_path2) header = HEADERS(dh_pair=dh_remote, pn=0, n=0) DHRatchet(state, header) - assert state.PN == 5 # Previous Ns - assert state.Ns == 0 # Reset - assert state.Nr == 0 # Reset - assert state.DHr == dh_remote.get_public_key() + assert state.PN == 5 + assert state.Ns == 0 assert state.CKr is not None assert state.CKs is not None - assert len(state.RK) == 32 class TestKDF: - """Test Key Derivation Functions""" + """Test key derivation functions.""" - def test_kdf_rk_generates_two_keys(self): - """Test KDF_RK generates root key and chain key""" - rk = secrets.token_bytes(32) - dh_out = secrets.token_bytes(32) - - new_rk, ck = KDF_RK(rk, dh_out) - - assert len(new_rk) == 32 - assert len(ck) == 32 - assert new_rk != rk - assert new_rk != ck - - def test_kdf_rk_deterministic(self): - """Test KDF_RK is deterministic with same inputs""" + def test_kdf_rk(self): + """Test KDF_RK generates deterministic keys.""" rk = secrets.token_bytes(32) dh_out = secrets.token_bytes(32) @@ -317,20 +181,10 @@ def test_kdf_rk_deterministic(self): assert new_rk1 == new_rk2 assert ck1 == ck2 + assert len(new_rk1) == 32 - def test_kdf_ck_generates_chain_and_message_key(self): - """Test KDF_CK generates new chain key and message key""" - ck = secrets.token_bytes(32) - - new_ck, mk = KDF_CK(ck) - - assert len(new_ck) == 32 - assert len(mk) == 32 - assert new_ck != ck - assert new_ck != mk - - def test_kdf_ck_deterministic(self): - """Test KDF_CK is deterministic with same inputs""" + def test_kdf_ck(self): + """Test KDF_CK generates deterministic keys.""" ck = secrets.token_bytes(32) new_ck1, mk1 = KDF_CK(ck) @@ -338,61 +192,29 @@ def test_kdf_ck_deterministic(self): assert new_ck1 == new_ck2 assert mk1 == mk2 - - def test_kdf_ck_chain_progression(self): - """Test KDF_CK can be chained for multiple message keys""" - ck = secrets.token_bytes(32) - - ck1, mk1 = KDF_CK(ck) - ck2, mk2 = KDF_CK(ck1) - ck3, mk3 = KDF_CK(ck2) - - assert ck != ck1 != ck2 != ck3 - assert mk1 != mk2 != mk3 + assert len(new_ck1) == 32 -class TestEncryptionDecryption: - """Test ENCRYPT and DECRYPT functions""" +class TestEncryption: + """Test encryption and decryption.""" def test_encrypt_decrypt_roundtrip(self): - """Test encryption and decryption roundtrip""" + """Test encryption and decryption.""" mk = secrets.token_bytes(32) - plaintext = b"Hello, World! This is a test message." - associated_data = b"associated_data" + plaintext = b"Hello, World!" + associated_data = b"metadata" ciphertext = ENCRYPT(mk, plaintext, associated_data) decrypted = DECRYPT(mk, ciphertext, associated_data) assert decrypted == plaintext - - def test_encrypt_produces_different_output(self): - """Test plaintext and ciphertext are different""" - mk = secrets.token_bytes(32) - plaintext = b"Secret message" - associated_data = b"metadata" - - ciphertext = ENCRYPT(mk, plaintext, associated_data) - assert ciphertext != plaintext - assert len(ciphertext) > len(plaintext) - - def test_encrypt_with_different_keys(self): - """Test encryption with different keys produces different outputs""" - mk1 = secrets.token_bytes(32) - mk2 = secrets.token_bytes(32) - plaintext = b"Secret message" - associated_data = b"metadata" - - ciphertext1 = ENCRYPT(mk1, plaintext, associated_data) - ciphertext2 = ENCRYPT(mk2, plaintext, associated_data) - - assert ciphertext1 != ciphertext2 - def test_decrypt_with_wrong_key_fails(self): - """Test decryption with wrong key fails""" + def test_decrypt_wrong_key_fails(self): + """Test decryption with wrong key fails.""" mk1 = secrets.token_bytes(32) mk2 = secrets.token_bytes(32) - plaintext = b"Secret message" + plaintext = b"Secret" associated_data = b"metadata" ciphertext = ENCRYPT(mk1, plaintext, associated_data) @@ -400,71 +222,44 @@ def test_decrypt_with_wrong_key_fails(self): with pytest.raises(ValueError): DECRYPT(mk2, ciphertext, associated_data) - def test_decrypt_with_wrong_associated_data_fails(self): - """Test decryption with wrong associated data fails""" + def test_decrypt_wrong_ad_fails(self): + """Test decryption with wrong associated data fails.""" mk = secrets.token_bytes(32) - plaintext = b"Secret message" - associated_data1 = b"metadata1" - associated_data2 = b"metadata2" + plaintext = b"Secret" - ciphertext = ENCRYPT(mk, plaintext, associated_data1) + ciphertext = ENCRYPT(mk, plaintext, b"ad1") with pytest.raises(ValueError): - DECRYPT(mk, ciphertext, associated_data2) + DECRYPT(mk, ciphertext, b"ad2") - def test_decrypt_with_tampered_ciphertext_fails(self): - """Test decryption with tampered ciphertext fails""" + def test_decrypt_tampered_ciphertext_fails(self): + """Test decryption with tampered ciphertext fails.""" mk = secrets.token_bytes(32) - plaintext = b"Secret message" + plaintext = b"Secret" associated_data = b"metadata" ciphertext = ENCRYPT(mk, plaintext, associated_data) tampered = bytearray(ciphertext) tampered[0] ^= 0xFF - tampered = bytes(tampered) with pytest.raises(ValueError): - DECRYPT(mk, tampered, associated_data) - - def test_encrypt_empty_message(self): - """Test encryption and decryption of empty message""" - mk = secrets.token_bytes(32) - plaintext = b"" - associated_data = b"metadata" - - ciphertext = ENCRYPT(mk, plaintext, associated_data) - decrypted = DECRYPT(mk, ciphertext, associated_data) - - assert decrypted == plaintext - - def test_encrypt_long_message(self): - """Test encryption and decryption of long message""" - mk = secrets.token_bytes(32) - plaintext = b"A" * 10000 - associated_data = b"metadata" - - ciphertext = ENCRYPT(mk, plaintext, associated_data) - decrypted = DECRYPT(mk, ciphertext, associated_data) - - assert decrypted == plaintext + DECRYPT(mk, bytes(tampered), associated_data) class TestCONCAT: - """Test CONCAT function""" + """Test CONCAT function.""" def setup_method(self): - """Setup test fixtures""" self.keystore_path = "db_keys/test_concat.db" self.dh_keypair = x25519(keystore_path=self.keystore_path) self.dh_keypair.init() def teardown_method(self): - """Cleanup test files""" if os.path.exists(self.keystore_path): os.remove(self.keystore_path) - def test_concat_combines_ad_and_header(self): - """Test CONCAT combines associated data and header""" + def test_concat(self): + """Test CONCAT combines data and header.""" ad = b"associated_data" header = HEADERS(dh_pair=self.dh_keypair, pn=5, n=10) @@ -473,86 +268,30 @@ def test_concat_combines_ad_and_header(self): assert result.startswith(ad) assert len(result) == len(ad) + len(header.serialize()) - def test_concat_deterministic(self): - """Test CONCAT is deterministic""" - ad = b"test_data" - header = HEADERS(dh_pair=self.dh_keypair, pn=3, n=7) - - result1 = CONCAT(ad, header) - result2 = CONCAT(ad, header) - - assert result1 == result2 - - def test_concat_empty_associated_data(self): - """Test CONCAT with empty associated data""" - ad = b"" - header = HEADERS(dh_pair=self.dh_keypair, pn=1, n=2) - - result = CONCAT(ad, header) - - assert result == header.serialize() - class TestIntegration: - """Integration tests combining multiple components""" + """Integration tests.""" def setup_method(self): - """Setup test fixtures""" - self.keystore_path1 = "db_keys/test_int1.db" - self.keystore_path2 = "db_keys/test_int2.db" + self.keystore_path = "db_keys/test_int.db" def teardown_method(self): - """Cleanup test files""" - for path in [self.keystore_path1, self.keystore_path2]: - if os.path.exists(path): - os.remove(path) - - def test_complete_message_exchange(self): - """Test complete message exchange between two parties""" - # Setup Alice's state - alice_state = States() - alice_state.DHs = GENERATE_DH(keystore_path=self.keystore_path1) - alice_state.RK = secrets.token_bytes(32) - alice_state.CKs = secrets.token_bytes(32) - alice_state.Ns = 0 - - # Setup Bob's initial DH - bob_dh = GENERATE_DH(keystore_path=self.keystore_path2) - alice_state.DHr = bob_dh.get_public_key() - - # Alice encrypts a message - plaintext = b"Hello Bob!" - header = HEADERS(dh_pair=alice_state.DHs, pn=alice_state.PN, n=alice_state.Ns) - ad = CONCAT(b"", header) - - new_ck, mk = KDF_CK(alice_state.CKs) - ciphertext = ENCRYPT(mk, plaintext, ad) - - # Verify Bob can decrypt - decrypted = DECRYPT(mk, ciphertext, ad) - assert decrypted == plaintext + if os.path.exists(self.keystore_path): + os.remove(self.keystore_path) def test_state_persistence(self): - """Test state can be persisted and restored""" - # Create and populate state + """Test state can be persisted and restored.""" state1 = States() - state1.DHs = GENERATE_DH(keystore_path=self.keystore_path1) + state1.DHs = GENERATE_DH(keystore_path=self.keystore_path) state1.RK = secrets.token_bytes(32) state1.DHr = secrets.token_bytes(32) state1.CKs = secrets.token_bytes(32) - state1.CKr = secrets.token_bytes(32) state1.Ns = 10 - state1.Nr = 5 - state1.PN = 8 state1.MKSKIPPED = {(b"key", 1): b"value"} - # Serialize and deserialize serialized = state1.serialize() state2 = States.deserialize(serialized) - # Verify all fields preserved assert state2.Ns == 10 - assert state2.Nr == 5 - assert state2.PN == 8 assert state2.MKSKIPPED == {(b"key", 1): b"value"} - assert state2 == state1 + assert states_equal(state2, state1) diff --git a/tests/test_x25519_keypairs.py b/tests/test_x25519_keypairs.py index a30542d..62016ad 100644 --- a/tests/test_x25519_keypairs.py +++ b/tests/test_x25519_keypairs.py @@ -1,9 +1,9 @@ -""" -Tests for the x25519 key exchange mechanism. -""" +"""Tests for x25519 keypair operations.""" import os + import pytest + from smswithoutborders_libsig.keypairs import x25519 @@ -19,11 +19,7 @@ def keypair_paths(tmp_path): def test_keypair_initialization(keypair_paths): - """Test the initialization of Alice's and Bob's public keys. - - Ensures that the public keys are not None, are of type bytes, - and have the correct length. - """ + """Test keypair initialization generates valid public keys.""" alice_db_path, bob_db_path = keypair_paths alice = x25519(alice_db_path) @@ -41,11 +37,7 @@ def test_keypair_initialization(keypair_paths): def test_key_agreement_protocol(keypair_paths): - """Test the key agreement protocol between Alice and Bob. - - Verifies that the shared keys are correctly generated, are equal, - and have the correct length. - """ + """Test key agreement produces matching shared secrets.""" alice_db_path, bob_db_path = keypair_paths alice = x25519(alice_db_path) @@ -67,31 +59,18 @@ def test_key_agreement_protocol(keypair_paths): def test_invalid_key_agreement(keypair_paths): - """Test the key agreement with invalid inputs. - - Ensures that appropriate exceptions are raised for invalid inputs. - """ - alice_db_path, bob_db_path = keypair_paths + """Test key agreement rejects invalid public keys.""" + alice_db_path, _ = keypair_paths alice = x25519(alice_db_path) - bob = x25519(bob_db_path) - - alice_public_key = alice.init() - bob_public_key = bob.init() + alice.init() with pytest.raises(ValueError): alice.agree(b"invalid_key") - with pytest.raises(ValueError): - bob.agree(b"invalid_key") - -def test_keypair_reinitialization(keypair_paths): - """Test the reinitialization of the x25519 object with existing keys. - - Ensures that x25519 objects can be reinitialized using the same database - paths and secret keys, and that the key agreement process remains functional. - """ +def test_keypair_serialization(keypair_paths): + """Test keypair serialization and deserialization.""" alice_db_path, bob_db_path = keypair_paths alice = x25519(alice_db_path) @@ -100,32 +79,16 @@ def test_keypair_reinitialization(keypair_paths): alice_public_key = alice.init() bob_public_key = bob.init() - alice_pnt_keystore = alice.pnt_keystore - alice_secret_key = alice.secret_key - bob_pnt_keystore = bob.pnt_keystore - bob_secret_key = bob.secret_key + alice_pnt = alice.pnt_keystore + alice_secret = alice.secret_key del alice - del bob - alice = x25519( - pnt_keystore=alice_pnt_keystore, - keystore_path=alice_db_path, - secret_key=alice_secret_key, - ) - bob = x25519( - pnt_keystore=bob_pnt_keystore, - keystore_path=bob_db_path, - secret_key=bob_secret_key, + alice_restored = x25519( + pnt_keystore=alice_pnt, keystore_path=alice_db_path, secret_key=alice_secret ) - alice_shared_key = alice.agree(bob_public_key) + alice_shared_key = alice_restored.agree(bob_public_key) bob_shared_key = bob.agree(alice_public_key) - assert alice_shared_key is not None - assert bob_shared_key is not None - assert isinstance(alice_shared_key, bytes) - assert isinstance(bob_shared_key, bytes) - assert len(alice_shared_key) == 32 - assert len(bob_shared_key) == 32 assert alice_shared_key == bob_shared_key From 0208bad7714d477cb9864c4d16d5a7b81e45f62b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 05:11:00 +0000 Subject: [PATCH 34/35] build(deps): bump sqlcipher3 from 0.5.4 to 0.6.0 Bumps [sqlcipher3](https://github.com/coleifer/sqlcipher3) from 0.5.4 to 0.6.0. - [Commits](https://github.com/coleifer/sqlcipher3/compare/0.5.4...0.6.0) --- updated-dependencies: - dependency-name: sqlcipher3 dependency-version: 0.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 39fd88d..9523fee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ cryptography==46.0.3 pycparser==2.23 pycryptodome==3.23.0 six==1.17.0 -sqlcipher3==0.5.4 +sqlcipher3==0.6.0 From d28908fa16dd17292f2ec86b4ce0078c023420f6 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Tue, 6 Jan 2026 12:09:40 +0100 Subject: [PATCH 35/35] chore: remove .DS_Store and add to .gitignore --- .DS_Store | Bin 8196 -> 0 bytes .gitignore | 1 + 2 files changed, 1 insertion(+) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 26edd3ac8067f14e5c6b34d05164e75f698cfa68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMF;5gh6n^9G#Y;%cl@u1+DG4#cFIdP4HYO^C6hw5n%R#tvFCJis&Bo4{P!KDA zf`$GGLIowEq_DHHFgAvS@68OiGwiMgNr=QZmzi%f@4bEBd^@|Fw?{;3dc8G4G)hEu zbjHRAhLXm1?uB;4&fEqn#8bC3F}IZV`UOv!5Cud5Q9u+B1w?^=MFG6Cxz#J)`^GAx zC?E>_mkRLnAwp-2EoKJwqXUC30e~T4rBb00LGFWf!5kB9a=bD7=E8+`bVQ{!DN z?|;@onkaCc6bKC2QLg_VRDb`!PD>K5qJSuHK?PLUoNG=a$kx_{aIUo@^cU#d*e^4v mLon!a9H`52;P4Ma97k~FnAl=w5Ho1