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.
@@ -0,0 +1,10 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Ignored default folder with query files
7
+ /queries/
8
+ # Datasource local storage ignored files
9
+ /dataSources/
10
+ /dataSources.local.xml
@@ -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,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectRootManager" version="2" project-jdk-name="~/Desktop/PycharmProjects/incy_crypto_link/.venv" project-jdk-type="Python SDK" />
4
+ </project>
@@ -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()