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

1"""

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

3

4 The format used looks like this:

5

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

7'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk'

8

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

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

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

12

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

14 If 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...

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

21

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

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

24 helps 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

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

30 start of the base64 JSON.

31

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

33 These functions make use of all of them.

34"""

35

36 import base64

37 import datetime

38 import json

39 import time

40 import zlib

41

42 from plain.runtime import settings

43 from plain.utils.crypto import constant_time_compare, salted_hmac

44 from plain.utils.encoding import force_bytes

45 from plain.utils.module_loading import import_string

46 from plain.utils.regex_helper import _lazy_re_compile

47

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

49 BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

50

51

52 class BadSignature(Exception):

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

54

55 pass

56

57

58 class SignatureExpired(BadSignature):

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

60

61 pass

62

63

64 def 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

76 def 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

89 def b64_encode(s):

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

91

92

93 def b64_decode(s):

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

95 return base64.urlsafe_b64decode(s + pad)

96

97

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

99 return b64_encode(

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

101 ).decode()

102

103

104 def _cookie_signer_key(key):

105 # SECRET_KEYS items may be str or bytes.

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

107

108

109 def 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

118 class 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

131 def 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

155 def 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

177 class 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

258 class 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