Skip to content

Commit ad8b00b

Browse files
committed
Adding basic OpenPAYGO Token support - Encoding
1 parent 34991e5 commit ad8b00b

11 files changed

Lines changed: 445 additions & 3 deletions

File tree

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2023 OpenPAYGO
3+
Copyright (c) 2023 Eternum Ltd T/A Solaris Offgrid
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,69 @@
1-
# openpaygo-python
2-
A Python library for adding support for OpenPAYGO technologies on servers.
1+
# OpenPAYGOToken - Python Lib
2+
3+
This repository contains the Python library for using implementing the different OpenPAYGOToken Suite technologies on your server (for generating tokens and decoding openpaygo metrics payloads) or device (for decoding tokens and making openpaygo metrics payloads).
4+
5+
<p align="center">
6+
<img
7+
alt="Project Status"
8+
src="https://img.shields.io/badge/Project%20Status-beta-orange"
9+
>
10+
<a href="https://github.com/openpaygo/metrics/blob/main/LICENSE" target="_blank">
11+
<img
12+
alt="License"
13+
src="https://img.shields.io/github/license/openpaygo/openpaygo-python"
14+
>
15+
</a>
16+
</p>
17+
18+
This open-source project was sponsored by:
19+
- Solaris Offgrid
20+
- EnAccess
21+
22+
## Table of Contents
23+
24+
- [Installing the library](#installing-the-library)
25+
- [Getting Started - Generating Token](#getting-started---generating-token)
26+
27+
28+
## Installing the library
29+
30+
You can install the library by running `pip install openpaygo` or adding `openpaygo` in your requirements.txt file and running `pip install -r requirements.txt`.
31+
32+
## Getting Started - Generating Token
33+
34+
You can use the `generate_token()` function to generate an OpenPAYGOToken Token. The function takes the following parameters, and they should match the configuration in the hardware of the device:
35+
36+
- `secret_key` (required): The secret key of the device
37+
- `value` (required): The value to be passed in the token (typically the number of days of activation)
38+
- `count` (required): The token count used to make the last token.
39+
- `set_mode` (optional): If set to `true`, the generated token will be a Set Time token, otherwise it is Add Time token.
40+
- `starting_code` (optional): If not provided, it is generated according to the method defined in the standard (SipHash-2-4 of the key, transformed to digit by the same method as the token generation).
41+
- `value_divider` (optional): The dividing factor used for the value.
42+
- `restricted_digit_set` (optional): If set to `true`, the the restricted digit set will be used (only digits from 1 to 4).
43+
- `extended_token` (optional): If set to `true` then a larger token will be generated, able to contain values up to 999999. This is for special use cases of each device, such as settings change, and is not set in the standard.
44+
45+
46+
The function returns the `token` as a string as well as the `updated_count` as a number.
47+
48+
49+
**Example:**
50+
51+
```
52+
from openpaygo.token import generate_token
53+
from myexampleproject import device_getter
54+
55+
# We get a device with the parameters we need from our database, this will be specific to your project
56+
device = device_getter(serial=1234)
57+
58+
# We get the new token and update the count
59+
new_token, device.count = generate_token(
60+
secret_key=device.secret_key,
61+
value=7,
62+
count=device.count
63+
)
64+
65+
print('Token: '+new_token)
66+
device.save() # We save the new count that we set for the device
67+
```
68+
69+
## Getting Started - Decoding a Token

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
siphash==0.0.1

setup.cfg

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[metadata]
2+
name = openpaygo
3+
version = 0.1.0
4+
url = https://github.com/openpaygo/openpaygo-python
5+
description-file=README.md
6+
license_files=LICENSE
7+
8+
[options]
9+
python_requires = >=2.7
10+
packages = find:
11+
include_package_data = true
12+
zip_safe = false
13+
install_requires =
14+
siphash >= 0.0.1

setup.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from setuptools import setup, find_packages
2+
3+
4+
setup(
5+
name="openpaygo",
6+
packages=find_packages(),
7+
version='0.1.0',
8+
license='MIT',
9+
author="Solaris Offgrid",
10+
url='https://github.com/openpaygo/openpaygo-python',
11+
install_requires=[
12+
'siphash',
13+
],
14+
)

token/__init__.py

Whitespace-only changes.

token/decode_token.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from shared import OpenPAYGOTokenShared
2+
from shared_extended import OpenPAYGOTokenSharedExtended
3+
4+
5+
class OpenPAYGOTokenDecoder(object):
6+
MAX_TOKEN_JUMP = 64
7+
MAX_TOKEN_JUMP_COUNTER_SYNC = 100
8+
MAX_UNUSED_OLDER_TOKENS = 8*2
9+
10+
@classmethod
11+
def get_activation_value_count_and_type_from_token(cls, token, starting_code, key, last_count,
12+
restricted_digit_set=False, used_counts=None):
13+
if restricted_digit_set:
14+
token = OpenPAYGOTokenShared.convert_from_4_digit_token(token)
15+
valid_older_token = False
16+
token_base = OpenPAYGOTokenShared.get_token_base(token) # We get the base of the token
17+
current_code = OpenPAYGOTokenShared.put_base_in_token(starting_code, token_base) # We put it into the starting code
18+
starting_code_base = OpenPAYGOTokenShared.get_token_base(starting_code) # We get the base of the starting code
19+
value = cls._decode_base(starting_code_base, token_base) # If there is a match we get the value from the token
20+
# We try all combination up until last_count + TOKEN_JUMP, or to the larger jump if syncing counter
21+
# We could start directly the loop at the last count if we kept the token value for the last count
22+
if value == OpenPAYGOTokenShared.COUNTER_SYNC_VALUE:
23+
max_count_try = last_count + cls.MAX_TOKEN_JUMP_COUNTER_SYNC + 1
24+
else:
25+
max_count_try = last_count + cls.MAX_TOKEN_JUMP + 1
26+
for count in range(0, max_count_try):
27+
masked_token = OpenPAYGOTokenShared.put_base_in_token(current_code, token_base)
28+
if count % 2:
29+
this_type = OpenPAYGOTokenShared.TOKEN_TYPE_SET_TIME
30+
else:
31+
this_type = OpenPAYGOTokenShared.TOKEN_TYPE_ADD_TIME
32+
if masked_token == token:
33+
if cls._count_is_valid(count, last_count, value, this_type, used_counts):
34+
return value, count, this_type
35+
else:
36+
valid_older_token = True
37+
current_code = OpenPAYGOTokenShared.generate_next_token(current_code, key) # If not we go to the next token
38+
if valid_older_token:
39+
return -2, None, None
40+
return None, None, None
41+
42+
@classmethod
43+
def _count_is_valid(cls, count, last_count, value, type, used_counts):
44+
if value == OpenPAYGOTokenShared.COUNTER_SYNC_VALUE:
45+
if count > last_count - 30:
46+
return True
47+
elif count > last_count:
48+
return True
49+
elif cls.MAX_UNUSED_OLDER_TOKENS > 0:
50+
if count > last_count - cls.MAX_UNUSED_OLDER_TOKENS:
51+
if count not in used_counts and type == OpenPAYGOTokenShared.TOKEN_TYPE_ADD_TIME:
52+
return True
53+
return False
54+
55+
@classmethod
56+
def update_used_counts(cls, past_used_counts, value, new_count, type):
57+
highest_count = max(past_used_counts) if past_used_counts else 0
58+
if new_count > highest_count:
59+
highest_count = new_count
60+
bottom_range = highest_count-cls.MAX_UNUSED_OLDER_TOKENS
61+
used_counts = []
62+
if type != OpenPAYGOTokenShared.TOKEN_TYPE_ADD_TIME or value == OpenPAYGOTokenShared.COUNTER_SYNC_VALUE or value == OpenPAYGOTokenShared.PAYG_DISABLE_VALUE:
63+
# If it is not an Add-Time token, we mark all the past tokens as used in the range
64+
for count in range(bottom_range, highest_count+1):
65+
used_counts.append(count)
66+
else:
67+
# If it is an Add-Time token, we just mark the tokens actually used in the range
68+
for count in range(bottom_range, highest_count+1):
69+
if count == new_count or count in past_used_counts:
70+
used_counts.append(count)
71+
return used_counts
72+
73+
@classmethod
74+
def _decode_base(cls, starting_code_base, token_base):
75+
decoded_value = token_base - starting_code_base
76+
if decoded_value < 0:
77+
return decoded_value + 1000
78+
else:
79+
return decoded_value
80+
81+
@classmethod
82+
def get_activation_value_count_from_extended_token(cls, token, starting_code, key, last_count,
83+
restricted_digit_set=False):
84+
if restricted_digit_set:
85+
token = OpenPAYGOTokenSharedExtended.convert_from_4_digit_token(token)
86+
token_base = OpenPAYGOTokenSharedExtended.get_token_base(token) # We get the base of the token
87+
current_code = OpenPAYGOTokenSharedExtended.put_base_in_token(starting_code, token_base) # We put it into the starting code
88+
starting_code_base = OpenPAYGOTokenSharedExtended.get_token_base(starting_code) # We get the base of the starting code
89+
value = cls._decode_base_extended(starting_code_base, token_base) # If there is a match we get the value from the token
90+
for count in range(0, 30):
91+
masked_token = OpenPAYGOTokenSharedExtended.put_base_in_token(current_code, token_base)
92+
if masked_token == token and count > last_count:
93+
clean_count = count-1
94+
return value, clean_count
95+
current_code = OpenPAYGOTokenSharedExtended.generate_next_token(current_code, key) # If not we go to the next token
96+
return None, None, None
97+
98+
@classmethod
99+
def _decode_base_extended(cls, starting_code_base, token_base):
100+
decoded_value = token_base - starting_code_base
101+
if decoded_value < 0:
102+
return decoded_value + 1000000
103+
else:
104+
return decoded_value

token/encode_token.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from shared import OpenPAYGOTokenShared
2+
from shared_extended import OpenPAYGOTokenSharedExtended
3+
4+
5+
class OpenPAYGOTokenEncoder(object):
6+
7+
@classmethod
8+
def generate_token(cls, secret_key, value, count, set_mode=False, starting_code=None, value_divider=1, restricted_digit_set=False, extended_token=False):
9+
if not starting_code:
10+
# We generate the starting code from the key if not provided
11+
starting_code = cls.generate_starting_code(secret_key)
12+
if set_mode:
13+
mode = OpenPAYGOTokenShared.TOKEN_TYPE_SET_TIME
14+
else:
15+
mode = OpenPAYGOTokenShared.TOKEN_TYPE_ADD_TIME
16+
value = int(round(value / value_divider))
17+
if extended_token:
18+
return cls.generate_extended_token(starting_code, secret_key, value, count, mode, restricted_digit_set)
19+
else:
20+
return cls.generate_standard_token(starting_code, secret_key, value, count, mode, restricted_digit_set)
21+
22+
@classmethod
23+
def generate_standard_token(cls, starting_code=None, key=None, value=None, count=None,
24+
mode=OpenPAYGOTokenShared.TOKEN_TYPE_SET_TIME, restricted_digit_set=False):
25+
# We get the first 3 digits with encoded value
26+
starting_code_base = OpenPAYGOTokenShared.get_token_base(starting_code)
27+
token_base = cls._encode_base(starting_code_base, value)
28+
current_token = OpenPAYGOTokenShared.put_base_in_token(starting_code, token_base)
29+
new_count = cls._get_new_count(count, mode)
30+
for xn in range(0, new_count):
31+
current_token = OpenPAYGOTokenShared.generate_next_token(current_token, key)
32+
final_token = OpenPAYGOTokenShared.put_base_in_token(current_token, token_base)
33+
if restricted_digit_set:
34+
final_token = OpenPAYGOTokenShared.convert_to_4_digit_token(final_token)
35+
final_token = '{:015d}'.format(final_token)
36+
else:
37+
final_token = '{:09d}'.format(final_token)
38+
return new_count, final_token
39+
40+
@classmethod
41+
def _encode_base(cls, base, number):
42+
if number + base > 999:
43+
return number + base - 1000
44+
else:
45+
return number + base
46+
47+
@classmethod
48+
def generate_extended_token(cls, starting_code, key, value, count, mode=OpenPAYGOTokenShared.TOKEN_TYPE_SET_TIME, restricted_digit_set=False):
49+
starting_code_base = OpenPAYGOTokenSharedExtended.get_token_base(starting_code)
50+
token_base = cls._encode_base_extended(starting_code_base, value)
51+
current_token = OpenPAYGOTokenSharedExtended.put_base_in_token(starting_code, token_base)
52+
new_count = cls._get_new_count(count, mode)
53+
for xn in range(0, new_count):
54+
current_token = OpenPAYGOTokenSharedExtended.generate_next_token(current_token, key)
55+
final_token = OpenPAYGOTokenSharedExtended.put_base_in_token(current_token, token_base)
56+
if restricted_digit_set:
57+
final_token = OpenPAYGOTokenSharedExtended.convert_to_4_digit_token(final_token)
58+
final_token = '{:020d}'.format(final_token)
59+
else:
60+
final_token = '{:012d}'.format(final_token)
61+
return new_count, final_token
62+
63+
@classmethod
64+
def generate_starting_code(cls, key):
65+
# We make a hash of the key
66+
starting_hash = OpenPAYGOTokenShared.generate_hash(key, key)
67+
return OpenPAYGOTokenShared.convert_hash_to_token(starting_hash)
68+
69+
@classmethod
70+
def _encode_base_extended(cls, base, number):
71+
if number + base > 999999:
72+
return number + base - 1000000
73+
else:
74+
return number + base
75+
76+
@classmethod
77+
def _get_new_count(cls, count, mode):
78+
current_count_odd = count % 2
79+
if mode == OpenPAYGOTokenShared.TOKEN_TYPE_SET_TIME:
80+
if current_count_odd: # Odd numbers are for Set Time
81+
new_count = count+2
82+
else:
83+
new_count = count+1
84+
else:
85+
if current_count_odd: # Even numbers are for Add Time
86+
new_count = count+1
87+
else:
88+
new_count = count+2
89+
return new_count

token/legacy.py

Whitespace-only changes.

token/shared.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import siphash
2+
import struct
3+
4+
5+
class OpenPAYGOTokenShared(object):
6+
MAX_BASE = 999
7+
MAX_ACTIVATION_VALUE = 995
8+
PAYG_DISABLE_VALUE = 998
9+
COUNTER_SYNC_VALUE = 999
10+
TOKEN_VALUE_OFFSET = 1000
11+
TOKEN_TYPE_SET_TIME = 1
12+
TOKEN_TYPE_ADD_TIME = 2
13+
14+
@classmethod
15+
def get_token_base(cls, code):
16+
return int(code) % cls.TOKEN_VALUE_OFFSET
17+
18+
@classmethod
19+
def put_base_in_token(cls, token, token_base):
20+
if token_base > cls.MAX_BASE:
21+
Exception('INVALID_VALUE')
22+
return token - cls.get_token_base(token) + token_base
23+
24+
@classmethod
25+
def generate_next_token(cls, last_code, key):
26+
conformed_token = struct.pack('>L', last_code) # We convert the token to bytes
27+
conformed_token += conformed_token # We duplicate it to fit the minimum length
28+
token_hash = cls.generate_hash(key, conformed_token) # We hash it
29+
new_token = cls.convert_hash_to_token(token_hash) # We convert to token and return
30+
return new_token
31+
32+
@classmethod
33+
def convert_hash_to_token(cls, this_hash):
34+
hash_int = struct.pack('>Q', this_hash) # We convert the hash to bytes
35+
hi_hash = struct.unpack('>L', hash_int[0:4])[0] # We split it in two 32bits INT
36+
lo_hash = struct.unpack('>L', hash_int[4:8])[0]
37+
result_hash = hi_hash ^ lo_hash # We XOR the two together to get a single 32bits INT
38+
token = cls._convert_to_29_5_bits(result_hash) # We convert the 32bits value to an INT no greater than 9 digits
39+
return token
40+
41+
@classmethod
42+
def _convert_to_29_5_bits(cls, source):
43+
mask = ((1 << (32 - 2 + 1)) - 1) << 2
44+
temp = (source & mask) >> 2
45+
if temp > 999999999:
46+
temp = temp - 73741825
47+
return temp
48+
49+
@classmethod
50+
def convert_to_4_digit_token(cls, source):
51+
restricted_digit_token = ''
52+
bit_array = cls._bit_array_from_int(source, 30)
53+
for i in range(15):
54+
this_array = bit_array[i*2:(i*2)+2]
55+
restricted_digit_token += str(cls._bit_array_to_int(this_array)+1)
56+
return int(restricted_digit_token)
57+
58+
@classmethod
59+
def convert_from_4_digit_token(cls, source):
60+
bit_array = []
61+
for digit in str(source):
62+
digit = int(digit) - 1
63+
this_array = cls._bit_array_from_int(digit, 2)
64+
bit_array += this_array
65+
return cls._bit_array_to_int(bit_array)
66+
67+
@classmethod
68+
def generate_hash(cls, key, value):
69+
return siphash.SipHash_2_4(key, value).hash()
70+
71+
@classmethod
72+
def _bit_array_to_int(cls, bit_array):
73+
integer = 0
74+
for bit in bit_array:
75+
integer = (integer << 1) | bit
76+
return integer
77+
78+
@classmethod
79+
def _bit_array_from_int(cls, source, bits):
80+
bit_array = []
81+
for i in range(bits):
82+
bit_array += [bool(source & (1 << (bits - 1 - i)))]
83+
return bit_array

0 commit comments

Comments
 (0)