Coverage for gcid/gcid.py: 78%

146 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-07-28 08:43 -0700

1"""Cryptographic, location aware ID conversions""" 

2 

3import hashlib 

4import hmac 

5import logging 

6from enum import Enum 

7 

8import base58 

9from cryptography.hazmat.backends import default_backend 

10from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 

11from pydantic import BaseModel 

12 

13from gcid.config import Config 

14 

15_config = Config() 

16 

17DIGEST_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 = b"id" 

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 

38cipher = Cipher(algorithms.AES(_ENC_KEY), modes.CBC(_IV), backend=default_backend()) 

39 

40log = logging.getLogger(__name__) 

41 

42 

43class IdType(Enum): 

44 """Type enum for ID prefixes""" 

45 

46 PROFILE = "prf" 

47 ORG = "org" 

48 ASSET = "asset" 

49 FILE = "file" 

50 EVENT = "evt" 

51 TOPO = "topo" 

52 JOBDEF = "jobdef" 

53 JOB = "job" 

54 JOBRESULT = "jobres" 

55 READER = "read" 

56 TAG = "tag" 

57 

58 

59class ApiError(Exception): 

60 def __init__(self, message): 

61 super().__init__(message) 

62 

63 

64class IdError(Exception): 

65 def __init__(self, id_str: str, reason: str): 

66 super().__init__(f"API ID {id_str} is not valid: {reason}") 

67 

68 

69class SequenceError(ApiError): 

70 def __init__(self, seq_num: int, reason: str): 

71 super().__init__(f"sequence number {seq_num} is not valid: {reason}") 

72 

73 

74id_rev_map = {i.value: i for i in list(IdType)} 

75 

76 

77class DbSeq(BaseModel): 

78 id_type: IdType 

79 prefix: bytes 

80 seq: int 

81 

82 

83def seq_to_id(api_type: IdType, seq: int | None) -> str: 

84 """Given a 64bit integer, return an encrypted version with a fixed HMAC""" 

85 if seq is None: 

86 raise SequenceError(0, "sequence is none") 

87 

88 if type(seq) is not int: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 raise SequenceError(seq, f"invalid type {type(seq)}") 

90 

91 # encrypt first 16 bytes (note this must be done in 128bit blocks or else padded) 

92 seq_bytes = seq.to_bytes(8, "big") 

93 e = cipher.encryptor() 

94 encrypted = e.update(_API_ID_PREFIX + seq_bytes) 

95 if len(encrypted) != _ENCRYPTED_BYTES: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 raise SequenceError(seq, f"invalid encrypted length {len(encrypted)}") 

97 

98 h = hashlib.blake2b(digest_size=DIGEST_SIZE, key=_HMAC_KEY, person=_PERSON) 

99 h.update(encrypted) 

100 digest = h.digest() 

101 combined = encrypted + digest[0:_HMAC_BYTES] 

102 encoded = base58.b58encode(combined) 

103 

104 return "".join((api_type.value, "_", encoded.decode("utf-8"))) 

105 

106 

107def id_type(api_id: str) -> IdType: 

108 """Return the ID type from the API ID""" 

109 if type(api_id) is not str: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 raise IdError(api_id, f"invalid api id type {type(api_id)}") 

111 

112 parts = api_id.split("_") 

113 if len(parts) != 2: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 raise IdError(api_id, f"invalid api id format {api_id}") 

115 

116 type_str, api_id = parts 

117 return id_rev_map[type_str] 

118 

119 

120def id_to_seq(api_id: str, api_id_type: IdType) -> int: 

121 """Validate and decode an encrypted serial number""" 

122 if type(api_id) is not str: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 raise IdError(api_id, f"IDs must be of string type: {type(api_id)}") 

124 

125 parts = api_id.split("_") 

126 if len(parts) != 2: 

127 raise IdError(api_id, f"ID has invalid format: {api_id}") 

128 

129 type_str, api_id = parts 

130 if api_id_type != id_rev_map.get(type_str): 

131 raise IdError(api_id, f"ID has invalid type: {type_str}") 

132 

133 # Base58 decode the obfuscated serial number 

134 combined = base58.b58decode(api_id) 

135 

136 # Extract the encrypted serial number and HMAC 

137 encrypted: bytes = combined[0:_ENCRYPTED_BYTES] 

138 serial_hmac = combined[_ENCRYPTED_BYTES:] 

139 if len(serial_hmac) != _HMAC_BYTES: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 raise IdError(api_id, "HMAC byte count mismatch") 

141 

142 # Verify the HMAC 

143 h = hashlib.blake2b(digest_size=DIGEST_SIZE, key=_HMAC_KEY, person=_PERSON) 

144 h.update(encrypted) 

145 digest = h.digest() 

146 if not hmac.compare_digest(serial_hmac, digest[0:_HMAC_BYTES]): 

147 raise IdError(api_id, "Invalid HMAC - data may have been tampered with") 

148 

149 # Decrypt the serial number 

150 d = cipher.decryptor() 

151 decrypted_data = d.update(encrypted) 

152 prefix = decrypted_data[0:_PREFIX_BYTES] 

153 seq = decrypted_data[_PREFIX_BYTES:] 

154 if len(seq) != _SEQ_BYTES: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 raise IdError(api_id, f"Invalid sequence bytes {len(seq)}") 

156 

157 if prefix != _API_ID_PREFIX: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 raise IdError(api_id, f"ID has invalid prefix: {prefix}") 

159 

160 return int.from_bytes(seq, "big") 

161 

162 

163def asset_seq_to_id(asset_seq: int) -> str: 

164 """Convert asset seq to ID""" 

165 return seq_to_id(IdType.ASSET, asset_seq) 

166 

167 

168def asset_id_to_seq(asset_id: str) -> int: 

169 """Convert asset ID to encrypted seq""" 

170 return id_to_seq(asset_id, IdType.ASSET) 

171 

172 

173def profile_seq_to_id(profile_seq: int) -> str: 

174 """Convert asset seq to ID""" 

175 return seq_to_id(IdType.PROFILE, profile_seq) 

176 

177 

178def profile_id_to_seq(profile_id: str) -> int: 

179 """Convert asset ID to encrypted seq""" 

180 return id_to_seq(profile_id, IdType.PROFILE) 

181 

182 

183def org_seq_to_id(org_seq: int) -> str: 

184 """Convert asset seq to ID""" 

185 return seq_to_id(IdType.ORG, org_seq) 

186 

187 

188def org_id_to_seq(org_id: str) -> int | None: 

189 """Convert asset ID to encrypted seq""" 

190 return id_to_seq(org_id, IdType.ORG) 

191 

192 

193def file_seq_to_id(file_seq: int) -> str: 

194 """Convert asset seq to ID""" 

195 return seq_to_id(IdType.FILE, file_seq) 

196 

197 

198def file_id_to_seq(file_id: str) -> int: 

199 """Convert asset ID to encrypted seq""" 

200 return id_to_seq(file_id, IdType.FILE) 

201 

202 

203def event_seq_to_id(file_seq: int) -> str: 

204 """Convert asset seq to ID""" 

205 return seq_to_id(IdType.EVENT, file_seq) 

206 

207 

208def event_id_to_seq(file_id: str) -> int: 

209 """Convert asset ID to encrypted seq""" 

210 return id_to_seq(file_id, IdType.EVENT) 

211 

212 

213def topo_seq_to_id(topo_seq: int) -> str: 

214 """Convert asset seq to ID""" 

215 return seq_to_id(IdType.TOPO, topo_seq) 

216 

217 

218def topo_id_to_seq(topo_id: str) -> int: 

219 """Convert asset ID to encrypted seq""" 

220 return id_to_seq(topo_id, IdType.TOPO) 

221 

222 

223def job_def_seq_to_id(job_def_seq: int) -> str: 

224 """Convert job def seq to ID""" 

225 return seq_to_id(IdType.JOBDEF, job_def_seq) 

226 

227 

228def job_def_id_to_seq(job_def_id: str) -> int: 

229 """Convert job def ID to encrypted seq""" 

230 return id_to_seq(job_def_id, IdType.JOBDEF) 

231 

232 

233def job_seq_to_id(job_seq: int) -> str: 

234 """Convert job seq to ID""" 

235 return seq_to_id(IdType.JOB, job_seq) 

236 

237 

238def job_id_to_seq(job_id: str) -> int: 

239 """Convert job ID to encrypted seq""" 

240 return id_to_seq(job_id, IdType.JOB) 

241 

242 

243def job_result_seq_to_id(job_result_seq: int) -> str: 

244 """Convert job result seq to ID""" 

245 return seq_to_id(IdType.JOBRESULT, job_result_seq) 

246 

247 

248def job_result_id_to_seq(job_result_id: str) -> int: 

249 """Convert job result ID to encrypted seq""" 

250 return id_to_seq(job_result_id, IdType.JOBRESULT) 

251 

252 

253def reader_seq_to_id(reader_seq: int) -> str: 

254 """Convert reader seq to encrypted ID""" 

255 return seq_to_id(IdType.READER, reader_seq) 

256 

257 

258def reader_id_to_seq(reader_id: str) -> int: 

259 """Convert reader ID to database seq""" 

260 return id_to_seq(reader_id, IdType.READER) 

261 

262 

263def tag_seq_to_id(tag_seq: int) -> str: 

264 """Convert tag seq to encrypted ID""" 

265 return seq_to_id(IdType.TAG, tag_seq) 

266 

267 

268def tag_id_to_seq(tag_id: str) -> int: 

269 """Convert tag ID to database seq""" 

270 return id_to_seq(tag_id, IdType.TAG)