incy-crypto-link 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- incy_crypto_link-1.2.0/.idea/.gitignore +10 -0
- incy_crypto_link-1.2.0/.idea/incy-crypto-link.iml +14 -0
- incy_crypto_link-1.2.0/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- incy_crypto_link-1.2.0/.idea/misc.xml +4 -0
- incy_crypto_link-1.2.0/.idea/modules.xml +8 -0
- incy_crypto_link-1.2.0/.idea/workspace.xml +152 -0
- incy_crypto_link-1.2.0/PKG-INFO +64 -0
- incy_crypto_link-1.2.0/README.md +51 -0
- incy_crypto_link-1.2.0/incy_crypto_link/__init__.py +187 -0
- incy_crypto_link-1.2.0/incy_crypto_link/_keymat.py +3 -0
- incy_crypto_link-1.2.0/pyproject.toml +21 -0
- incy_crypto_link-1.2.0/tests/test_encoder.py +85 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager">
|
|
4
|
+
<content url="file://$MODULE_DIR$">
|
|
5
|
+
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
6
|
+
</content>
|
|
7
|
+
<orderEntry type="jdk" jdkName="~/Desktop/PycharmProjects/incy_crypto_link/.venv" jdkType="Python SDK" />
|
|
8
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
9
|
+
</component>
|
|
10
|
+
<component name="PackageRequirementsSettings" />
|
|
11
|
+
<component name="PyDocumentationSettings" />
|
|
12
|
+
<component name="ReSTService" />
|
|
13
|
+
<component name="TestRunnerService" />
|
|
14
|
+
</module>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ProjectModuleManager">
|
|
4
|
+
<modules>
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/incy-crypto-link.iml" filepath="$PROJECT_DIR$/.idea/incy-crypto-link.iml" />
|
|
6
|
+
</modules>
|
|
7
|
+
</component>
|
|
8
|
+
</project>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="RunManager">
|
|
4
|
+
<configuration default="true" type="DjangoTestsConfigurationType">
|
|
5
|
+
<module name="incy-crypto-link" />
|
|
6
|
+
<option name="ENV_FILES" value="" />
|
|
7
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
8
|
+
<option name="PARENT_ENVS" value="true" />
|
|
9
|
+
<envs>
|
|
10
|
+
<env name="PYTHONUNBUFFERED" value="1" />
|
|
11
|
+
</envs>
|
|
12
|
+
<option name="SDK_HOME" value="" />
|
|
13
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
14
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
15
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
16
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
17
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
18
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
19
|
+
<option name="RUN_TOOL" value="" />
|
|
20
|
+
<option name="TARGET" value="" />
|
|
21
|
+
<option name="SETTINGS_FILE" value="" />
|
|
22
|
+
<option name="CUSTOM_SETTINGS" value="false" />
|
|
23
|
+
<option name="USE_OPTIONS" value="false" />
|
|
24
|
+
<option name="OPTIONS" value="" />
|
|
25
|
+
<method v="2" />
|
|
26
|
+
</configuration>
|
|
27
|
+
<configuration default="true" type="PythonConfigurationType" factoryName="Python">
|
|
28
|
+
<module name="incy-crypto-link" />
|
|
29
|
+
<option name="ENV_FILES" value="" />
|
|
30
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
31
|
+
<option name="PARENT_ENVS" value="true" />
|
|
32
|
+
<envs>
|
|
33
|
+
<env name="PYTHONUNBUFFERED" value="1" />
|
|
34
|
+
</envs>
|
|
35
|
+
<option name="SDK_HOME" value="" />
|
|
36
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
37
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
38
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
39
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
40
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
41
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
42
|
+
<option name="RUN_TOOL" value="" />
|
|
43
|
+
<option name="SCRIPT_NAME" value="" />
|
|
44
|
+
<option name="PARAMETERS" value="" />
|
|
45
|
+
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
46
|
+
<option name="EMULATE_TERMINAL" value="false" />
|
|
47
|
+
<option name="MODULE_MODE" value="false" />
|
|
48
|
+
<option name="REDIRECT_INPUT" value="false" />
|
|
49
|
+
<option name="INPUT_FILE" value="" />
|
|
50
|
+
<method v="2" />
|
|
51
|
+
</configuration>
|
|
52
|
+
<configuration default="true" type="Python.Celery" factoryName="Python.Celery.App">
|
|
53
|
+
<module name="incy-crypto-link" />
|
|
54
|
+
<option name="ENV_FILES" value="" />
|
|
55
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
56
|
+
<option name="PARENT_ENVS" value="true" />
|
|
57
|
+
<option name="SDK_HOME" value="" />
|
|
58
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
59
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
60
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
61
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
62
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
63
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
64
|
+
<option name="RUN_TOOL" value="" />
|
|
65
|
+
<method v="2" />
|
|
66
|
+
</configuration>
|
|
67
|
+
<configuration default="true" type="Python.Celery" factoryName="Python.Celery.Task">
|
|
68
|
+
<module name="incy-crypto-link" />
|
|
69
|
+
<option name="ENV_FILES" value="" />
|
|
70
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
71
|
+
<option name="PARENT_ENVS" value="true" />
|
|
72
|
+
<option name="SDK_HOME" value="" />
|
|
73
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
74
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
75
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
76
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
77
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
78
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
79
|
+
<option name="RUN_TOOL" value="" />
|
|
80
|
+
<method v="2" />
|
|
81
|
+
</configuration>
|
|
82
|
+
<configuration default="true" type="Python.FlaskServer">
|
|
83
|
+
<module name="incy-crypto-link" />
|
|
84
|
+
<option name="ENV_FILES" value="" />
|
|
85
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
86
|
+
<option name="PARENT_ENVS" value="true" />
|
|
87
|
+
<option name="SDK_HOME" value="" />
|
|
88
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
89
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
90
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
91
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
92
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
93
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
94
|
+
<option name="RUN_TOOL" value="" />
|
|
95
|
+
<option name="launchJavascriptDebuger" value="false" />
|
|
96
|
+
<method v="2" />
|
|
97
|
+
</configuration>
|
|
98
|
+
<configuration default="true" type="Tox" factoryName="Tox">
|
|
99
|
+
<module name="incy-crypto-link" />
|
|
100
|
+
<option name="ENV_FILES" value="" />
|
|
101
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
102
|
+
<option name="PARENT_ENVS" value="true" />
|
|
103
|
+
<option name="SDK_HOME" value="" />
|
|
104
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
105
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
106
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
107
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
108
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
109
|
+
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
110
|
+
<option name="RUN_TOOL" value="" />
|
|
111
|
+
<method v="2" />
|
|
112
|
+
</configuration>
|
|
113
|
+
<configuration default="true" type="docs" factoryName="Docutils task">
|
|
114
|
+
<module name="incy-crypto-link" />
|
|
115
|
+
<option name="ENV_FILES" value="" />
|
|
116
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
117
|
+
<option name="PARENT_ENVS" value="true" />
|
|
118
|
+
<option name="SDK_HOME" value="" />
|
|
119
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
120
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
121
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
122
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
123
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
124
|
+
<option name="RUN_TOOL" value="" />
|
|
125
|
+
<option name="docutils_input_file" value="" />
|
|
126
|
+
<option name="docutils_output_file" value="" />
|
|
127
|
+
<option name="docutils_params" value="" />
|
|
128
|
+
<option name="docutils_task" value="" />
|
|
129
|
+
<option name="docutils_open_in_browser" value="false" />
|
|
130
|
+
<method v="2" />
|
|
131
|
+
</configuration>
|
|
132
|
+
<configuration default="true" type="docs" factoryName="Sphinx task">
|
|
133
|
+
<module name="incy-crypto-link" />
|
|
134
|
+
<option name="ENV_FILES" value="" />
|
|
135
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
136
|
+
<option name="PARENT_ENVS" value="true" />
|
|
137
|
+
<option name="SDK_HOME" value="" />
|
|
138
|
+
<option name="WORKING_DIRECTORY" value="" />
|
|
139
|
+
<option name="IS_MODULE_SDK" value="false" />
|
|
140
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
141
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
142
|
+
<option name="DEBUG_JUST_MY_CODE" value="false" />
|
|
143
|
+
<option name="RUN_TOOL" value="" />
|
|
144
|
+
<option name="docutils_input_file" value="" />
|
|
145
|
+
<option name="docutils_output_file" value="" />
|
|
146
|
+
<option name="docutils_params" value="" />
|
|
147
|
+
<option name="docutils_task" value="" />
|
|
148
|
+
<option name="docutils_open_in_browser" value="false" />
|
|
149
|
+
<method v="2" />
|
|
150
|
+
</configuration>
|
|
151
|
+
</component>
|
|
152
|
+
</project>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: incy-crypto-link
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Encode subscription URLs into incy://crypt1/<payload> deep links (Python port of @incy/link-encoder). AES-256-GCM with a key shared with the INCY iOS/Android/Desktop clients.
|
|
5
|
+
Project-URL: Homepage, https://incy.cc/encrypt
|
|
6
|
+
Project-URL: Repository, https://github.com/INCY-DEV/incy-link-encoder
|
|
7
|
+
Author-email: INCY <main@incy.cc>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: aes-gcm,deeplink,encryption,incy,vpn
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: cryptography>=41
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# incy_crypto_link (Python)
|
|
15
|
+
|
|
16
|
+
Python port of [`@incy/link-encoder`](https://github.com/INCY-DEV/incy-link-encoder) —
|
|
17
|
+
encode VPN subscription URLs into `incy://crypt1/<payload>` deep links
|
|
18
|
+
that the INCY iOS, Android, and Desktop clients decode automatically.
|
|
19
|
+
|
|
20
|
+
Wire-compatible with the JS/PHP/Go ports and the client apps; a pinned
|
|
21
|
+
test vector guards against drift.
|
|
22
|
+
|
|
23
|
+
> **This is obfuscation, not security.** The AES-256-GCM key is derived
|
|
24
|
+
> from constants and asset bytes embedded in this package. See the
|
|
25
|
+
> [main README](../README.md) for the full threat model.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install incy-crypto-link
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python ≥ 3.9 and [`cryptography`](https://pypi.org/project/cryptography/).
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from incy_crypto_link import encrypt_link, decrypt_link
|
|
39
|
+
|
|
40
|
+
link = encrypt_link("https://sub.your-provider.example/abc123token", name="My Provider VPN")
|
|
41
|
+
print(link)
|
|
42
|
+
# → incy://crypt1/AAECAwQFBgcICQoLNyIQL3rDwRZqnyoD8pGK…
|
|
43
|
+
|
|
44
|
+
decoded = decrypt_link(link)
|
|
45
|
+
print(decoded.url, decoded.name)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
encrypt_link(url: str, name: str | None = None) -> str
|
|
52
|
+
decrypt_link(link: str) -> DecryptedLink # .url, .name
|
|
53
|
+
encrypt_link_deterministic(url: str, iv: bytes, name: str | None = None) -> str # tests only
|
|
54
|
+
|
|
55
|
+
VERSION: str
|
|
56
|
+
SCHEME_VERSION: str # "crypt1"
|
|
57
|
+
KEY_FINGERPRINT: str # SHA-256 of K1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Tests
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python -m unittest discover -s tests -v
|
|
64
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# incy_crypto_link (Python)
|
|
2
|
+
|
|
3
|
+
Python port of [`@incy/link-encoder`](https://github.com/INCY-DEV/incy-link-encoder) —
|
|
4
|
+
encode VPN subscription URLs into `incy://crypt1/<payload>` deep links
|
|
5
|
+
that the INCY iOS, Android, and Desktop clients decode automatically.
|
|
6
|
+
|
|
7
|
+
Wire-compatible with the JS/PHP/Go ports and the client apps; a pinned
|
|
8
|
+
test vector guards against drift.
|
|
9
|
+
|
|
10
|
+
> **This is obfuscation, not security.** The AES-256-GCM key is derived
|
|
11
|
+
> from constants and asset bytes embedded in this package. See the
|
|
12
|
+
> [main README](../README.md) for the full threat model.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install incy-crypto-link
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python ≥ 3.9 and [`cryptography`](https://pypi.org/project/cryptography/).
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from incy_crypto_link import encrypt_link, decrypt_link
|
|
26
|
+
|
|
27
|
+
link = encrypt_link("https://sub.your-provider.example/abc123token", name="My Provider VPN")
|
|
28
|
+
print(link)
|
|
29
|
+
# → incy://crypt1/AAECAwQFBgcICQoLNyIQL3rDwRZqnyoD8pGK…
|
|
30
|
+
|
|
31
|
+
decoded = decrypt_link(link)
|
|
32
|
+
print(decoded.url, decoded.name)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
encrypt_link(url: str, name: str | None = None) -> str
|
|
39
|
+
decrypt_link(link: str) -> DecryptedLink # .url, .name
|
|
40
|
+
encrypt_link_deterministic(url: str, iv: bytes, name: str | None = None) -> str # tests only
|
|
41
|
+
|
|
42
|
+
VERSION: str
|
|
43
|
+
SCHEME_VERSION: str # "crypt1"
|
|
44
|
+
KEY_FINGERPRINT: str # SHA-256 of K1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Tests
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m unittest discover -s tests -v
|
|
51
|
+
```
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""incy-crypto-link — Python port of @incy/link-encoder.
|
|
2
|
+
|
|
3
|
+
Encode subscription URLs into ``incy://crypt1/<payload>`` deep links
|
|
4
|
+
that the INCY iOS/Android/Desktop clients know how to decode.
|
|
5
|
+
|
|
6
|
+
THIS IS OBFUSCATION, NOT SECURITY. The AES-256-GCM key is derived
|
|
7
|
+
from constants and asset bytes embedded in this package — anyone
|
|
8
|
+
reading the source can reconstruct it. See the README of the JS
|
|
9
|
+
package for the full threat model.
|
|
10
|
+
|
|
11
|
+
Wire format (identical across JS/Python/PHP/Go and the client apps):
|
|
12
|
+
|
|
13
|
+
incy://crypt1/base64url( iv[12] || ciphertext || tag[16] )
|
|
14
|
+
|
|
15
|
+
where the plaintext is compact JSON with sorted keys, e.g.
|
|
16
|
+
``{"n":"Name","url":"https://...","v":1}``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from base64 import b64decode, urlsafe_b64decode, urlsafe_b64encode
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from cryptography.exceptions import InvalidTag
|
|
28
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
29
|
+
|
|
30
|
+
from ._keymat import KEYMAT_A_B64, KEYMAT_B_B64
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DecryptedLink",
|
|
34
|
+
"KEY_FINGERPRINT",
|
|
35
|
+
"SCHEME_VERSION",
|
|
36
|
+
"VERSION",
|
|
37
|
+
"decrypt_link",
|
|
38
|
+
"encrypt_link",
|
|
39
|
+
"encrypt_link_deterministic",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Salt parts — concatenated in order and fed into the SHA-256 KDF.
|
|
43
|
+
# Split into four constants for the same greppability reasons as in
|
|
44
|
+
# the JS package and the client apps.
|
|
45
|
+
_SALT_P1 = "incy"
|
|
46
|
+
_SALT_P2 = "deep"
|
|
47
|
+
_SALT_P3 = "crypt1"
|
|
48
|
+
_SALT_P4 = "v2026.06"
|
|
49
|
+
|
|
50
|
+
_KEYMAT_A_OFFSET = 1024
|
|
51
|
+
_KEYMAT_B_OFFSET = 2048
|
|
52
|
+
_KEYMAT_LEN = 32
|
|
53
|
+
|
|
54
|
+
_EXPECTED_KEY_FINGERPRINT = (
|
|
55
|
+
"b6bf708471cc90043232967660aade86a50b4e57929db2e53c5fa34db624c08c"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_SCHEME = "incy"
|
|
59
|
+
_HOST = "crypt1"
|
|
60
|
+
_LINK_PREFIX = f"{_SCHEME}://{_HOST}/"
|
|
61
|
+
|
|
62
|
+
_IV_LEN = 12
|
|
63
|
+
_TAG_LEN = 16
|
|
64
|
+
|
|
65
|
+
VERSION = "1.2.0"
|
|
66
|
+
SCHEME_VERSION = "crypt1"
|
|
67
|
+
KEY_FINGERPRINT = _EXPECTED_KEY_FINGERPRINT
|
|
68
|
+
|
|
69
|
+
_key_cache: bytes | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _derive_key() -> bytes:
|
|
73
|
+
global _key_cache
|
|
74
|
+
if _key_cache is not None:
|
|
75
|
+
return _key_cache
|
|
76
|
+
a = b64decode(KEYMAT_A_B64)
|
|
77
|
+
b = b64decode(KEYMAT_B_B64)
|
|
78
|
+
if len(a) < _KEYMAT_A_OFFSET + _KEYMAT_LEN or len(b) < _KEYMAT_B_OFFSET + _KEYMAT_LEN:
|
|
79
|
+
raise ValueError("incy-link-encoder: keymat assets are smaller than expected")
|
|
80
|
+
seed = (
|
|
81
|
+
_SALT_P1.encode() + _SALT_P2.encode() + _SALT_P3.encode() + _SALT_P4.encode()
|
|
82
|
+
+ a[_KEYMAT_A_OFFSET : _KEYMAT_A_OFFSET + _KEYMAT_LEN]
|
|
83
|
+
+ b[_KEYMAT_B_OFFSET : _KEYMAT_B_OFFSET + _KEYMAT_LEN]
|
|
84
|
+
)
|
|
85
|
+
key = hashlib.sha256(seed).digest()
|
|
86
|
+
fp = hashlib.sha256(key).hexdigest()
|
|
87
|
+
if fp != _EXPECTED_KEY_FINGERPRINT:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"incy-link-encoder: derived K1 fingerprint mismatch "
|
|
90
|
+
f"(expected {_EXPECTED_KEY_FINGERPRINT}, got {fp}) — keymat assets are out "
|
|
91
|
+
"of sync with the published clients. Reinstall the package or report a bug."
|
|
92
|
+
)
|
|
93
|
+
_key_cache = key
|
|
94
|
+
return key
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _b64url_encode(data: bytes) -> str:
|
|
98
|
+
return urlsafe_b64encode(data).decode().rstrip("=")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _b64url_decode(s: str) -> bytes:
|
|
102
|
+
pad = "" if len(s) % 4 == 0 else "=" * (4 - len(s) % 4)
|
|
103
|
+
return urlsafe_b64decode(s + pad)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_plaintext(url: str, name: str | None) -> bytes:
|
|
107
|
+
if not url or not isinstance(url, str):
|
|
108
|
+
raise TypeError("encrypt_link: url must be a non-empty string")
|
|
109
|
+
payload: dict[str, object] = {"url": url, "v": 1}
|
|
110
|
+
if name:
|
|
111
|
+
payload["n"] = name[:128]
|
|
112
|
+
# Compact JSON with sorted keys — byte-for-byte identical to
|
|
113
|
+
# JSON.stringify over sorted keys in the JS package and to the
|
|
114
|
+
# sorted-keys serializers in the client apps.
|
|
115
|
+
return json.dumps(
|
|
116
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
117
|
+
).encode("utf-8")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class DecryptedLink:
|
|
122
|
+
"""Result of decrypting an ``incy://crypt1/...`` link."""
|
|
123
|
+
|
|
124
|
+
url: str
|
|
125
|
+
name: str | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def encrypt_link(url: str, name: str | None = None) -> str:
|
|
129
|
+
"""Encrypt a subscription URL into an ``incy://crypt1/<payload>`` link.
|
|
130
|
+
|
|
131
|
+
``name`` is an optional human-readable subscription name shown in
|
|
132
|
+
the receiving app's import sheet (truncated to 128 chars).
|
|
133
|
+
"""
|
|
134
|
+
plaintext = _build_plaintext(url, name)
|
|
135
|
+
key = _derive_key()
|
|
136
|
+
iv = os.urandom(_IV_LEN)
|
|
137
|
+
out = AESGCM(key).encrypt(iv, plaintext, None) # returns ct || tag
|
|
138
|
+
return _LINK_PREFIX + _b64url_encode(iv + out)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def encrypt_link_deterministic(url: str, iv: bytes, name: str | None = None) -> str:
|
|
142
|
+
"""Same as :func:`encrypt_link` with a caller-provided IV.
|
|
143
|
+
|
|
144
|
+
For test vectors only — never reuse an IV across different
|
|
145
|
+
plaintexts in production; that breaks AES-GCM confidentiality.
|
|
146
|
+
"""
|
|
147
|
+
if len(iv) != _IV_LEN:
|
|
148
|
+
raise ValueError("encrypt_link_deterministic: iv must be 12 bytes")
|
|
149
|
+
plaintext = _build_plaintext(url, name)
|
|
150
|
+
key = _derive_key()
|
|
151
|
+
out = AESGCM(key).encrypt(iv, plaintext, None)
|
|
152
|
+
return _LINK_PREFIX + _b64url_encode(iv + out)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decrypt_link(link: str) -> DecryptedLink:
|
|
156
|
+
"""Decrypt an ``incy://crypt1/<payload>`` link.
|
|
157
|
+
|
|
158
|
+
Raises ``ValueError`` on malformed input or authentication failure
|
|
159
|
+
(wrong key, tampered ciphertext).
|
|
160
|
+
"""
|
|
161
|
+
if not link or not isinstance(link, str):
|
|
162
|
+
raise TypeError("decrypt_link: link must be a non-empty string")
|
|
163
|
+
if not link.startswith(_LINK_PREFIX):
|
|
164
|
+
raise ValueError(f"decrypt_link: expected {_LINK_PREFIX} prefix")
|
|
165
|
+
payload = link[len(_LINK_PREFIX):].rstrip("/")
|
|
166
|
+
if not payload:
|
|
167
|
+
raise ValueError("decrypt_link: empty payload")
|
|
168
|
+
wire = _b64url_decode(payload)
|
|
169
|
+
if len(wire) < _IV_LEN + _TAG_LEN + 1:
|
|
170
|
+
raise ValueError("decrypt_link: payload too short")
|
|
171
|
+
iv, ct_and_tag = wire[:_IV_LEN], wire[_IV_LEN:]
|
|
172
|
+
|
|
173
|
+
key = _derive_key()
|
|
174
|
+
try:
|
|
175
|
+
plaintext = AESGCM(key).decrypt(iv, ct_and_tag, None)
|
|
176
|
+
except InvalidTag:
|
|
177
|
+
raise ValueError("decrypt_link: authentication failed") from None
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
parsed = json.loads(plaintext.decode("utf-8"))
|
|
181
|
+
except (ValueError, UnicodeDecodeError):
|
|
182
|
+
raise ValueError("decrypt_link: malformed plaintext") from None
|
|
183
|
+
url = parsed.get("url") if isinstance(parsed, dict) else None
|
|
184
|
+
if not isinstance(url, str) or not url:
|
|
185
|
+
raise ValueError("decrypt_link: missing url field")
|
|
186
|
+
name = parsed.get("n")
|
|
187
|
+
return DecryptedLink(url=url, name=name if isinstance(name, str) and name else None)
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Auto-generated from assets/*.bin. Run `npm run gen-keymat` to refresh.
|
|
2
|
+
KEYMAT_A_B64 = "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"
|
|
3
|
+
KEYMAT_B_B64 = "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"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "incy-crypto-link"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Encode subscription URLs into incy://crypt1/<payload> deep links (Python port of @incy/link-encoder). AES-256-GCM with a key shared with the INCY iOS/Android/Desktop clients."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "INCY", email = "main@incy.cc" }]
|
|
13
|
+
keywords = ["incy", "vpn", "deeplink", "encryption", "aes-gcm"]
|
|
14
|
+
dependencies = ["cryptography>=41"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://incy.cc/encrypt"
|
|
18
|
+
Repository = "https://github.com/INCY-DEV/incy-link-encoder"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["incy_crypto_link"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for the Python port. Mirrors test/index.test.ts, including
|
|
2
|
+
the pinned cross-platform test vector that guards wire compatibility
|
|
3
|
+
with the JS package and the iOS/Android/Desktop clients."""
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from incy_crypto_link import (
|
|
8
|
+
KEY_FINGERPRINT,
|
|
9
|
+
SCHEME_VERSION,
|
|
10
|
+
decrypt_link,
|
|
11
|
+
encrypt_link,
|
|
12
|
+
encrypt_link_deterministic,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
PINNED_FINGERPRINT = "b6bf708471cc90043232967660aade86a50b4e57929db2e53c5fa34db624c08c"
|
|
16
|
+
PINNED_VECTOR = (
|
|
17
|
+
"incy://crypt1/AAECAwQFBgcICQoLNyIQL3rDwRZqnyoD8pGKSLXP6o8NdSXQVSSALNbbUyIr"
|
|
18
|
+
"__tWGFUexdIfKvvmDnuDGbmBvuppfNef6aKNZUwOm4c-Sg"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LinkEncoderTest(unittest.TestCase):
|
|
23
|
+
def test_key_fingerprint_matches_cross_platform_constant(self):
|
|
24
|
+
self.assertEqual(KEY_FINGERPRINT, PINNED_FINGERPRINT)
|
|
25
|
+
self.assertEqual(SCHEME_VERSION, "crypt1")
|
|
26
|
+
|
|
27
|
+
def test_round_trip(self):
|
|
28
|
+
url = "https://sub.example.com/test-token"
|
|
29
|
+
link = encrypt_link(url)
|
|
30
|
+
self.assertTrue(link.startswith("incy://crypt1/"))
|
|
31
|
+
decoded = decrypt_link(link)
|
|
32
|
+
self.assertEqual(decoded.url, url)
|
|
33
|
+
self.assertIsNone(decoded.name)
|
|
34
|
+
|
|
35
|
+
def test_round_trip_with_name(self):
|
|
36
|
+
url = "https://sub.example.com/abc"
|
|
37
|
+
name = "MyProvider VPN"
|
|
38
|
+
decoded = decrypt_link(encrypt_link(url, name=name))
|
|
39
|
+
self.assertEqual(decoded.url, url)
|
|
40
|
+
self.assertEqual(decoded.name, name)
|
|
41
|
+
|
|
42
|
+
def test_deterministic_vector_matches_cross_platform_reference(self):
|
|
43
|
+
iv = bytes.fromhex("000102030405060708090a0b")
|
|
44
|
+
link = encrypt_link_deterministic("https://sub.example.com/test-vector", iv)
|
|
45
|
+
self.assertEqual(link, PINNED_VECTOR)
|
|
46
|
+
|
|
47
|
+
def test_unique_ciphertext_per_call(self):
|
|
48
|
+
url = "https://test.example/abc"
|
|
49
|
+
a, b = encrypt_link(url), encrypt_link(url)
|
|
50
|
+
self.assertNotEqual(a, b)
|
|
51
|
+
self.assertEqual(decrypt_link(a).url, url)
|
|
52
|
+
self.assertEqual(decrypt_link(b).url, url)
|
|
53
|
+
|
|
54
|
+
def test_rejects_tampered_payload(self):
|
|
55
|
+
link = encrypt_link("https://test.example/x")
|
|
56
|
+
flip = "B" if link[-10] == "A" else "A"
|
|
57
|
+
tampered = link[:-10] + flip + link[-9:]
|
|
58
|
+
with self.assertRaises(ValueError):
|
|
59
|
+
decrypt_link(tampered)
|
|
60
|
+
|
|
61
|
+
def test_rejects_non_crypt1_prefix(self):
|
|
62
|
+
for bad in ("https://incy.cc/foo", "incy://add/https%3A%2F%2Ffoo.bar"):
|
|
63
|
+
with self.assertRaises(ValueError):
|
|
64
|
+
decrypt_link(bad)
|
|
65
|
+
with self.assertRaises(TypeError):
|
|
66
|
+
decrypt_link("")
|
|
67
|
+
|
|
68
|
+
def test_rejects_empty_input(self):
|
|
69
|
+
with self.assertRaises(TypeError):
|
|
70
|
+
encrypt_link("")
|
|
71
|
+
with self.assertRaises(TypeError):
|
|
72
|
+
encrypt_link(None) # type: ignore[arg-type]
|
|
73
|
+
|
|
74
|
+
def test_rejects_wrong_iv_length(self):
|
|
75
|
+
with self.assertRaises(ValueError):
|
|
76
|
+
encrypt_link_deterministic("https://test/x", b"\x00" * 11)
|
|
77
|
+
|
|
78
|
+
def test_name_truncated_to_128_chars(self):
|
|
79
|
+
decoded = decrypt_link(encrypt_link("https://test/x", name="X" * 500))
|
|
80
|
+
assert decoded.name is not None
|
|
81
|
+
self.assertEqual(len(decoded.name), 128)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
unittest.main()
|