gcid 0.1.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.
- gcid-0.1.0/PKG-INFO +33 -0
- gcid-0.1.0/README.md +4 -0
- gcid-0.1.0/gcid/__init__.py +0 -0
- gcid-0.1.0/gcid/config.py +7 -0
- gcid-0.1.0/gcid/gcid.py +269 -0
- gcid-0.1.0/gcid/location.py +7 -0
- gcid-0.1.0/pyproject.toml +48 -0
gcid-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: gcid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Global Cryptographic IDs
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: database,api,web,crypto,identifiers
|
|
7
|
+
Author: Jacob Repp
|
|
8
|
+
Author-email: jacob@mythica.ai
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: base58 (>=2.1.1,<3.0.0)
|
|
17
|
+
Requires-Dist: cryptography (>=44.0.1,<45.0.0)
|
|
18
|
+
Requires-Dist: flake8 (>=7.1.2,<8.0.0)
|
|
19
|
+
Requires-Dist: munch (>=4.0.0,<5.0.0)
|
|
20
|
+
Requires-Dist: pydantic (>=2.10.6,<3.0.0)
|
|
21
|
+
Requires-Dist: pydantic_settings (>=2.7.1,<3.0.0)
|
|
22
|
+
Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
|
|
23
|
+
Requires-Dist: pylint (>=3.3.4,<4.0.0)
|
|
24
|
+
Requires-Dist: pytest (>=8.3.4,<9.0.0)
|
|
25
|
+
Requires-Dist: typing-extensions (>=4.12.2,<5.0.0)
|
|
26
|
+
Project-URL: Homepage, https://github.com/MythicaAI/jungle-ams/libs/python/gcid
|
|
27
|
+
Project-URL: Repository, https://github.com/MythicaAI/jungle-ams
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
cryptid
|
|
31
|
+
===
|
|
32
|
+
|
|
33
|
+
Cryptographic, location aware ID conversions
|
gcid-0.1.0/README.md
ADDED
|
File without changes
|
gcid-0.1.0/gcid/gcid.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Cryptographic, location aware ID conversions"""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
import base58
|
|
9
|
+
from cryptography.hazmat.backends import default_backend
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from gcid.config import Config
|
|
14
|
+
|
|
15
|
+
_config = Config()
|
|
16
|
+
|
|
17
|
+
DIGEST_SIZE = 16
|
|
18
|
+
|
|
19
|
+
# ID encoding constants
|
|
20
|
+
_HMAC_BYTES = 4
|
|
21
|
+
_SEQ_BYTES = 8
|
|
22
|
+
_PREFIX_BYTES = 8
|
|
23
|
+
_ENCRYPTED_BYTES = _PREFIX_BYTES + _SEQ_BYTES
|
|
24
|
+
|
|
25
|
+
_ENC_KEY: bytes = _config.gcid_enc_key.encode('utf-8')
|
|
26
|
+
_HMAC_KEY: bytes = _config.gcid_hmac_key.encode('utf-8')
|
|
27
|
+
_PERSON: bytes = 'id'.encode('utf-8')
|
|
28
|
+
|
|
29
|
+
# Generate a 16-byte Initialization Vector (IV)
|
|
30
|
+
_IV = b'\00' * 16
|
|
31
|
+
|
|
32
|
+
# ID metadata
|
|
33
|
+
_VERSION = b'\01'
|
|
34
|
+
_LOCATION_PARTITION = b'\00\00\00\00\00\00\00'
|
|
35
|
+
_API_ID_PREFIX = _VERSION + _LOCATION_PARTITION
|
|
36
|
+
|
|
37
|
+
# Create a Cipher object using AES algorithm in CBC mode
|
|
38
|
+
cipher = Cipher(algorithms.AES(_ENC_KEY), modes.CBC(_IV), backend=default_backend())
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IdType(Enum):
|
|
44
|
+
"""Type enum for ID prefixes"""
|
|
45
|
+
PROFILE = 'prf'
|
|
46
|
+
ORG = 'org'
|
|
47
|
+
ASSET = "asset"
|
|
48
|
+
FILE = "file"
|
|
49
|
+
EVENT = 'evt'
|
|
50
|
+
TOPO = 'topo'
|
|
51
|
+
JOBDEF = 'jobdef'
|
|
52
|
+
JOB = 'job'
|
|
53
|
+
JOBRESULT = 'jobres'
|
|
54
|
+
READER = 'read'
|
|
55
|
+
TAG = 'tag'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ApiError(Exception):
|
|
59
|
+
def __init__(self, message):
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class IdError(Exception):
|
|
64
|
+
def __init__(self, id_str: str, reason: str):
|
|
65
|
+
super().__init__(f'API ID {id_str} is not valid: {reason}')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SequenceError(ApiError):
|
|
69
|
+
def __init__(self, seq_num: int, reason: str):
|
|
70
|
+
super().__init__(f'sequence number {seq_num} is not valid: {reason}')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
id_rev_map = {i.value: i for i in list(IdType)}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DbSeq(BaseModel):
|
|
77
|
+
id_type: IdType
|
|
78
|
+
prefix: bytes
|
|
79
|
+
seq: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def seq_to_id(api_type: IdType, seq: int | None) -> str:
|
|
83
|
+
"""Given a 64bit integer, return an encrypted version with a fixed HMAC"""
|
|
84
|
+
if seq is None:
|
|
85
|
+
raise SequenceError(0, "sequence is none")
|
|
86
|
+
|
|
87
|
+
if type(seq) is not int:
|
|
88
|
+
raise SequenceError(seq, f"invalid type {type(seq)}")
|
|
89
|
+
|
|
90
|
+
# encrypt first 16 bytes (note this must be done in 128bit blocks or else padded)
|
|
91
|
+
seq_bytes = seq.to_bytes(8, 'big')
|
|
92
|
+
e = cipher.encryptor()
|
|
93
|
+
encrypted = e.update(_API_ID_PREFIX + seq_bytes)
|
|
94
|
+
if len(encrypted) != _ENCRYPTED_BYTES:
|
|
95
|
+
raise SequenceError(seq, f"invalid encrypted length {len(encrypted)}")
|
|
96
|
+
|
|
97
|
+
h = hashlib.blake2b(digest_size=DIGEST_SIZE, key=_HMAC_KEY, person=_PERSON)
|
|
98
|
+
h.update(encrypted)
|
|
99
|
+
digest = h.digest()
|
|
100
|
+
combined = encrypted + digest[0:_HMAC_BYTES]
|
|
101
|
+
encoded = base58.b58encode(combined)
|
|
102
|
+
|
|
103
|
+
return ''.join((api_type.value, '_', encoded.decode('utf-8')))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def id_type(api_id: str) -> IdType:
|
|
107
|
+
"""Return the ID type from the API ID"""
|
|
108
|
+
if type(api_id) is not str:
|
|
109
|
+
raise IdError(api_id, f"invalid api id type {type(api_id)}")
|
|
110
|
+
|
|
111
|
+
parts = api_id.split('_')
|
|
112
|
+
if len(parts) != 2:
|
|
113
|
+
raise IdError(api_id, f"invalid api id format {api_id}")
|
|
114
|
+
|
|
115
|
+
type_str, api_id = parts
|
|
116
|
+
return id_rev_map[type_str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def id_to_seq(api_id: str, api_id_type: IdType) -> int:
|
|
120
|
+
"""Validate and decode an encrypted serial number"""
|
|
121
|
+
if type(api_id) is not str:
|
|
122
|
+
raise IdError(api_id, f"IDs must be of string type: {type(api_id)}")
|
|
123
|
+
|
|
124
|
+
parts = api_id.split('_')
|
|
125
|
+
if len(parts) != 2:
|
|
126
|
+
raise IdError(api_id, f"ID has invalid format: {api_id}")
|
|
127
|
+
|
|
128
|
+
type_str, api_id = parts
|
|
129
|
+
if api_id_type != id_rev_map.get(type_str):
|
|
130
|
+
raise IdError(api_id, f"ID has invalid type: {type_str}")
|
|
131
|
+
|
|
132
|
+
# Base58 decode the obfuscated serial number
|
|
133
|
+
combined = base58.b58decode(api_id)
|
|
134
|
+
|
|
135
|
+
# Extract the encrypted serial number and HMAC
|
|
136
|
+
encrypted: bytes = combined[0:_ENCRYPTED_BYTES]
|
|
137
|
+
serial_hmac = combined[_ENCRYPTED_BYTES:]
|
|
138
|
+
if len(serial_hmac) != _HMAC_BYTES:
|
|
139
|
+
raise IdError(api_id, "HMAC byte count mismatch")
|
|
140
|
+
|
|
141
|
+
# Verify the HMAC
|
|
142
|
+
h = hashlib.blake2b(digest_size=DIGEST_SIZE, key=_HMAC_KEY, person=_PERSON)
|
|
143
|
+
h.update(encrypted)
|
|
144
|
+
digest = h.digest()
|
|
145
|
+
if not hmac.compare_digest(serial_hmac, digest[0:_HMAC_BYTES]):
|
|
146
|
+
raise IdError(api_id, "Invalid HMAC - data may have been tampered with")
|
|
147
|
+
|
|
148
|
+
# Decrypt the serial number
|
|
149
|
+
d = cipher.decryptor()
|
|
150
|
+
decrypted_data = d.update(encrypted)
|
|
151
|
+
prefix = decrypted_data[0:_PREFIX_BYTES]
|
|
152
|
+
seq = decrypted_data[_PREFIX_BYTES:]
|
|
153
|
+
if len(seq) != _SEQ_BYTES:
|
|
154
|
+
raise IdError(api_id, f"Invalid sequence bytes {len(seq)}")
|
|
155
|
+
|
|
156
|
+
if prefix != _API_ID_PREFIX:
|
|
157
|
+
raise IdError(api_id, f"ID has invalid prefix: {prefix}")
|
|
158
|
+
|
|
159
|
+
return int.from_bytes(seq, 'big')
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def asset_seq_to_id(asset_seq: int) -> str:
|
|
163
|
+
"""Convert asset seq to ID"""
|
|
164
|
+
return seq_to_id(IdType.ASSET, asset_seq)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def asset_id_to_seq(asset_id: str) -> int:
|
|
168
|
+
"""Convert asset ID to encrypted seq"""
|
|
169
|
+
return id_to_seq(asset_id, IdType.ASSET)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def profile_seq_to_id(profile_seq: int) -> str:
|
|
173
|
+
"""Convert asset seq to ID"""
|
|
174
|
+
return seq_to_id(IdType.PROFILE, profile_seq)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def profile_id_to_seq(profile_id: str) -> int:
|
|
178
|
+
"""Convert asset ID to encrypted seq"""
|
|
179
|
+
return id_to_seq(profile_id, IdType.PROFILE)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def org_seq_to_id(org_seq: int) -> str:
|
|
183
|
+
"""Convert asset seq to ID"""
|
|
184
|
+
return seq_to_id(IdType.ORG, org_seq)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def org_id_to_seq(org_id: str) -> int | None:
|
|
188
|
+
"""Convert asset ID to encrypted seq"""
|
|
189
|
+
return id_to_seq(org_id, IdType.ORG)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def file_seq_to_id(file_seq: int) -> str:
|
|
193
|
+
"""Convert asset seq to ID"""
|
|
194
|
+
return seq_to_id(IdType.FILE, file_seq)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def file_id_to_seq(file_id: str) -> int:
|
|
198
|
+
"""Convert asset ID to encrypted seq"""
|
|
199
|
+
return id_to_seq(file_id, IdType.FILE)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def event_seq_to_id(file_seq: int) -> str:
|
|
203
|
+
"""Convert asset seq to ID"""
|
|
204
|
+
return seq_to_id(IdType.EVENT, file_seq)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def event_id_to_seq(file_id: str) -> int:
|
|
208
|
+
"""Convert asset ID to encrypted seq"""
|
|
209
|
+
return id_to_seq(file_id, IdType.EVENT)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def topo_seq_to_id(topo_seq: int) -> str:
|
|
213
|
+
"""Convert asset seq to ID"""
|
|
214
|
+
return seq_to_id(IdType.TOPO, topo_seq)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def topo_id_to_seq(topo_id: str) -> int:
|
|
218
|
+
"""Convert asset ID to encrypted seq"""
|
|
219
|
+
return id_to_seq(topo_id, IdType.TOPO)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def job_def_seq_to_id(job_def_seq: int) -> str:
|
|
223
|
+
"""Convert job def seq to ID"""
|
|
224
|
+
return seq_to_id(IdType.JOBDEF, job_def_seq)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def job_def_id_to_seq(job_def_id: str) -> int:
|
|
228
|
+
"""Convert job def ID to encrypted seq"""
|
|
229
|
+
return id_to_seq(job_def_id, IdType.JOBDEF)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def job_seq_to_id(job_seq: int) -> str:
|
|
233
|
+
"""Convert job seq to ID"""
|
|
234
|
+
return seq_to_id(IdType.JOB, job_seq)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def job_id_to_seq(job_id: str) -> int:
|
|
238
|
+
"""Convert job ID to encrypted seq"""
|
|
239
|
+
return id_to_seq(job_id, IdType.JOB)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def job_result_seq_to_id(job_result_seq: int) -> str:
|
|
243
|
+
"""Convert job result seq to ID"""
|
|
244
|
+
return seq_to_id(IdType.JOBRESULT, job_result_seq)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def job_result_id_to_seq(job_result_id: str) -> int:
|
|
248
|
+
"""Convert job result ID to encrypted seq"""
|
|
249
|
+
return id_to_seq(job_result_id, IdType.JOBRESULT)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def reader_seq_to_id(reader_seq: int) -> str:
|
|
253
|
+
"""Convert reader seq to encrypted ID"""
|
|
254
|
+
return seq_to_id(IdType.READER, reader_seq)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def reader_id_to_seq(reader_id: str) -> int:
|
|
258
|
+
"""Convert reader ID to database seq"""
|
|
259
|
+
return id_to_seq(reader_id, IdType.READER)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def tag_seq_to_id(tag_seq: int) -> str:
|
|
263
|
+
"""Convert tag seq to encrypted ID"""
|
|
264
|
+
return seq_to_id(IdType.TAG, tag_seq)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def tag_id_to_seq(tag_id: str) -> int:
|
|
268
|
+
"""Convert tag ID to database seq"""
|
|
269
|
+
return id_to_seq(tag_id, IdType.TAG)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "gcid"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Global Cryptographic IDs"
|
|
5
|
+
authors = ["Jacob Repp <jacob@mythica.ai>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
homepage = "https://github.com/MythicaAI/jungle-ams/libs/python/gcid"
|
|
9
|
+
repository = "https://github.com/MythicaAI/jungle-ams"
|
|
10
|
+
keywords = ["database", "api", "web", "crypto", "identifiers"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = "<4.0,>=3.10"
|
|
15
|
+
typing-extensions = "^4.12.2"
|
|
16
|
+
pydantic = "^2.10.6"
|
|
17
|
+
base58 = "^2.1.1"
|
|
18
|
+
cryptography = "^44.0.1"
|
|
19
|
+
pydantic_settings = "^2.7.1"
|
|
20
|
+
flake8 = "^7.1.2"
|
|
21
|
+
munch = "^4.0.0"
|
|
22
|
+
pylint = "^3.3.4"
|
|
23
|
+
pytest = "^8.3.4"
|
|
24
|
+
pyjwt = "^2.10.1"
|
|
25
|
+
|
|
26
|
+
[tool.poetry.group.dev.dependencies]
|
|
27
|
+
pytest = "^8.3.4"
|
|
28
|
+
flake8 = "^7.1.2"
|
|
29
|
+
munch = "^4.0.0"
|
|
30
|
+
pylint = "^3.3.4"
|
|
31
|
+
pytest-cov = "^6.0.0"
|
|
32
|
+
pytest-timeout = "^2.3.1"
|
|
33
|
+
|
|
34
|
+
# Add development dependencies here
|
|
35
|
+
[tool.poetry.scripts]
|
|
36
|
+
start = "main:start"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["poetry-core>=1.0.0"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|
|
41
|
+
|
|
42
|
+
[tool.coverage.report]
|
|
43
|
+
omit = [
|
|
44
|
+
"tests/*",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
addopts = "-p no:logging"
|