Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/signing.py: 33%

118 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:03 -0500

1""" 

2Functions for creating and restoring url-safe signed JSON objects. 

3 

4The format used looks like this: 

5 

6>>> signing.dumps("hello") 

7'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk' 

8 

9There are two components here, separated by a ':'. The first component is a 

10URLsafe base64 encoded JSON of the object passed to dumps(). The second 

11component is a base64 encoded hmac/SHA-256 hash of "$first_component:$secret" 

12 

13signing.loads(s) checks the signature and returns the deserialized object. 

14If the signature fails, a BadSignature exception is raised. 

15 

16>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk") 

17'hello' 

18>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified") 

19... 

20BadSignature: Signature "ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified" does not match 

21 

22You can optionally compress the JSON prior to base64 encoding it to save 

23space, using the compress=True argument. This checks if compression actually 

24helps and only applies compression if the result is a shorter string: 

25 

26>>> signing.dumps(list(range(1, 20)), compress=True) 

27'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ' 

28 

29The fact that the string is compressed is signalled by the prefixed '.' at the 

30start of the base64 JSON. 

31 

32There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'. 

33These functions make use of all of them. 

34""" 

35 

36import base64 

37import datetime 

38import json 

39import time 

40import zlib 

41 

42from plain.runtime import settings 

43from plain.utils.crypto import constant_time_compare, salted_hmac 

44from plain.utils.encoding import force_bytes 

45from plain.utils.module_loading import import_string 

46from plain.utils.regex_helper import _lazy_re_compile 

47 

48_SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$") 

49BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 

50 

51 

52class BadSignature(Exception): 

53 """Signature does not match.""" 

54 

55 pass 

56 

57 

58class SignatureExpired(BadSignature): 

59 """Signature timestamp is older than required max_age.""" 

60 

61 pass 

62 

63 

64def b62_encode(s): 

65 if s == 0: 

66 return "0" 

67 sign = "-" if s < 0 else "" 

68 s = abs(s) 

69 encoded = "" 

70 while s > 0: 

71 s, remainder = divmod(s, 62) 

72 encoded = BASE62_ALPHABET[remainder] + encoded 

73 return sign + encoded 

74 

75 

76def b62_decode(s): 

77 if s == "0": 

78 return 0 

79 sign = 1 

80 if s[0] == "-": 

81 s = s[1:] 

82 sign = -1 

83 decoded = 0 

84 for digit in s: 

85 decoded = decoded * 62 + BASE62_ALPHABET.index(digit) 

86 return sign * decoded 

87 

88 

89def b64_encode(s): 

90 return base64.urlsafe_b64encode(s).strip(b"=") 

91 

92 

93def b64_decode(s): 

94 pad = b"=" * (-len(s) % 4) 

95 return base64.urlsafe_b64decode(s + pad) 

96 

97 

98def base64_hmac(salt, value, key, algorithm="sha1"): 

99 return b64_encode( 

100 salted_hmac(salt, value, key, algorithm=algorithm).digest() 

101 ).decode() 

102 

103 

104def _cookie_signer_key(key): 

105 # SECRET_KEYS items may be str or bytes. 

106 return b"plain.http.cookies" + force_bytes(key) 

107 

108 

109def get_cookie_signer(salt="plain.signing.get_cookie_signer"): 

110 Signer = import_string(settings.COOKIE_SIGNING_BACKEND) 

111 return Signer( 

112 key=_cookie_signer_key(settings.SECRET_KEY), 

113 fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS), 

114 salt=salt, 

115 ) 

116 

117 

118class JSONSerializer: 

119 """ 

120 Simple wrapper around json to be used in signing.dumps and 

121 signing.loads. 

122 """ 

123 

124 def dumps(self, obj): 

125 return json.dumps(obj, separators=(",", ":")).encode("latin-1") 

126 

127 def loads(self, data): 

128 return json.loads(data.decode("latin-1")) 

129 

130 

131def dumps( 

132 obj, key=None, salt="plain.signing", serializer=JSONSerializer, compress=False 

133): 

134 """ 

135 Return URL-safe, hmac signed base64 compressed JSON string. If key is 

136 None, use settings.SECRET_KEY instead. The hmac algorithm is the default 

137 Signer algorithm. 

138 

139 If compress is True (not the default), check if compressing using zlib can 

140 save some space. Prepend a '.' to signify compression. This is included 

141 in the signature, to protect against zip bombs. 

142 

143 Salt can be used to namespace the hash, so that a signed string is 

144 only valid for a given namespace. Leaving this at the default 

145 value or re-using a salt value across different parts of your 

146 application without good cause is a security risk. 

147 

148 The serializer is expected to return a bytestring. 

149 """ 

150 return TimestampSigner(key=key, salt=salt).sign_object( 

151 obj, serializer=serializer, compress=compress 

152 ) 

153 

154 

155def loads( 

156 s, 

157 key=None, 

158 salt="plain.signing", 

159 serializer=JSONSerializer, 

160 max_age=None, 

161 fallback_keys=None, 

162): 

163 """ 

164 Reverse of dumps(), raise BadSignature if signature fails. 

165 

166 The serializer is expected to accept a bytestring. 

167 """ 

168 return TimestampSigner( 

169 key=key, salt=salt, fallback_keys=fallback_keys 

170 ).unsign_object( 

171 s, 

172 serializer=serializer, 

173 max_age=max_age, 

174 ) 

175 

176 

177class Signer: 

178 def __init__( 

179 self, 

180 *, 

181 key=None, 

182 sep=":", 

183 salt=None, 

184 algorithm="sha256", 

185 fallback_keys=None, 

186 ): 

187 self.key = key or settings.SECRET_KEY 

188 self.fallback_keys = ( 

189 fallback_keys 

190 if fallback_keys is not None 

191 else settings.SECRET_KEY_FALLBACKS 

192 ) 

193 self.sep = sep 

194 self.salt = salt or f"{self.__class__.__module__}.{self.__class__.__name__}" 

195 self.algorithm = algorithm 

196 

197 if _SEP_UNSAFE.match(self.sep): 

198 raise ValueError( 

199 "Unsafe Signer separator: %r (cannot be empty or consist of " 

200 "only A-z0-9-_=)" % sep, 

201 ) 

202 

203 def signature(self, value, key=None): 

204 key = key or self.key 

205 return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm) 

206 

207 def sign(self, value): 

208 return f"{value}{self.sep}{self.signature(value)}" 

209 

210 def unsign(self, signed_value): 

211 if self.sep not in signed_value: 

212 raise BadSignature('No "%s" found in value' % self.sep) 

213 value, sig = signed_value.rsplit(self.sep, 1) 

214 for key in [self.key, *self.fallback_keys]: 

215 if constant_time_compare(sig, self.signature(value, key)): 

216 return value 

217 raise BadSignature('Signature "%s" does not match' % sig) 

218 

219 def sign_object(self, obj, serializer=JSONSerializer, compress=False): 

220 """ 

221 Return URL-safe, hmac signed base64 compressed JSON string. 

222 

223 If compress is True (not the default), check if compressing using zlib 

224 can save some space. Prepend a '.' to signify compression. This is 

225 included in the signature, to protect against zip bombs. 

226 

227 The serializer is expected to return a bytestring. 

228 """ 

229 data = serializer().dumps(obj) 

230 # Flag for if it's been compressed or not. 

231 is_compressed = False 

232 

233 if compress: 

234 # Avoid zlib dependency unless compress is being used. 

235 compressed = zlib.compress(data) 

236 if len(compressed) < (len(data) - 1): 

237 data = compressed 

238 is_compressed = True 

239 base64d = b64_encode(data).decode() 

240 if is_compressed: 

241 base64d = "." + base64d 

242 return self.sign(base64d) 

243 

244 def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs): 

245 # Signer.unsign() returns str but base64 and zlib compression operate 

246 # on bytes. 

247 base64d = self.unsign(signed_obj, **kwargs).encode() 

248 decompress = base64d[:1] == b"." 

249 if decompress: 

250 # It's compressed; uncompress it first. 

251 base64d = base64d[1:] 

252 data = b64_decode(base64d) 

253 if decompress: 

254 data = zlib.decompress(data) 

255 return serializer().loads(data) 

256 

257 

258class TimestampSigner(Signer): 

259 def timestamp(self): 

260 return b62_encode(int(time.time())) 

261 

262 def sign(self, value): 

263 value = f"{value}{self.sep}{self.timestamp()}" 

264 return super().sign(value) 

265 

266 def unsign(self, value, max_age=None): 

267 """ 

268 Retrieve original value and check it wasn't signed more 

269 than max_age seconds ago. 

270 """ 

271 result = super().unsign(value) 

272 value, timestamp = result.rsplit(self.sep, 1) 

273 timestamp = b62_decode(timestamp) 

274 if max_age is not None: 

275 if isinstance(max_age, datetime.timedelta): 

276 max_age = max_age.total_seconds() 

277 # Check timestamp is not older than max_age 

278 age = time.time() - timestamp 

279 if age > max_age: 

280 raise SignatureExpired(f"Signature age {age} > {max_age} seconds") 

281 return value