baseh 1.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.
- baseh-1.1.0/PKG-INFO +125 -0
- baseh-1.1.0/README.md +117 -0
- baseh-1.1.0/pyproject.toml +18 -0
- baseh-1.1.0/setup.cfg +4 -0
- baseh-1.1.0/src/baseh/__init__.py +62 -0
- baseh-1.1.0/src/baseh/basen.py +36 -0
- baseh-1.1.0/src/baseh/blocklist.py +41 -0
- baseh-1.1.0/src/baseh/checksum.py +36 -0
- baseh-1.1.0/src/baseh/codec.py +242 -0
- baseh-1.1.0/src/baseh/errors.py +36 -0
- baseh-1.1.0/src/baseh/feistel.py +119 -0
- baseh-1.1.0/src/baseh/profile.py +205 -0
- baseh-1.1.0/src/baseh/profiles.py +146 -0
- baseh-1.1.0/src/baseh/zero.py +52 -0
- baseh-1.1.0/src/baseh.egg-info/PKG-INFO +125 -0
- baseh-1.1.0/src/baseh.egg-info/SOURCES.txt +19 -0
- baseh-1.1.0/src/baseh.egg-info/dependency_links.txt +1 -0
- baseh-1.1.0/src/baseh.egg-info/top_level.txt +1 -0
- baseh-1.1.0/tests/test_codec.py +460 -0
- baseh-1.1.0/tests/test_vectors.py +166 -0
- baseh-1.1.0/tests/test_zero.py +91 -0
baseh-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: baseh
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: baseH codec: checksumed, optionally permuted human-readable identifiers
|
|
5
|
+
License: AGPL-3.0-only
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# baseh
|
|
10
|
+
|
|
11
|
+
Python implementation of the baseH codec. Encodes an internal integer ID as
|
|
12
|
+
a checksummed, optionally permuted human-readable reference code. Implements
|
|
13
|
+
the normative spec in `../spec/IMPLEMENTATION_CODEC.md` and passes the
|
|
14
|
+
frozen cross-language vectors in `../vectors/`.
|
|
15
|
+
|
|
16
|
+
Zero runtime dependencies. HMAC-SHA-256 comes from the standard library.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install ./python
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or run in place without installing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
PYTHONPATH=python/src python3 -c "import baseh"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from baseh import Baseh, BasehError, baseh_medium_v1
|
|
34
|
+
|
|
35
|
+
codec = Baseh(baseh_medium_v1())
|
|
36
|
+
|
|
37
|
+
code = codec.encode(123456789)
|
|
38
|
+
print(code) # fixed-length, checksummed code
|
|
39
|
+
|
|
40
|
+
result = codec.decode(code.lower()) # case-insensitive
|
|
41
|
+
print(result.id) # 123456789
|
|
42
|
+
print(result.canonical_code) # canonical rendering
|
|
43
|
+
print(result.corrected) # False (input differed only in case)
|
|
44
|
+
|
|
45
|
+
# Assisted correction over spoken-confusion pairs (B/D, P/T, ...):
|
|
46
|
+
fixed = codec.decode(
|
|
47
|
+
code,
|
|
48
|
+
try_correction=True,
|
|
49
|
+
confusion_profile="light",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Non-throwing validation for user input:
|
|
53
|
+
check = codec.validate("0000000")
|
|
54
|
+
print(check) # {"valid": False, "reason": "INVALID_CHECKSUM"}
|
|
55
|
+
|
|
56
|
+
print(codec.capacity()) # 481890304
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Errors raise `BasehError` with a `.code` attribute, one of:
|
|
60
|
+
`INVALID_PROFILE`, `OUT_OF_RANGE`, `PERMUTATION_FAILURE`, `INVALID_LENGTH`,
|
|
61
|
+
`INVALID_CHARACTER`, `INVALID_CHECKSUM`, `AMBIGUOUS_INPUT`,
|
|
62
|
+
`TOO_MANY_CANDIDATES`, `BLOCKED_CODE`.
|
|
63
|
+
|
|
64
|
+
## Frozen tiers
|
|
65
|
+
|
|
66
|
+
Four frozen profiles, each 6 body symbols and case-insensitive, built from
|
|
67
|
+
the full alphanumeric set with cumulative visual and spoken strips:
|
|
68
|
+
|
|
69
|
+
| Tier | Helper | Symbols | Checksum | Capacity |
|
|
70
|
+
|------|--------|---------|----------|----------|
|
|
71
|
+
| Minimum | `baseh_minimum_v1()` | 36 | none | 2,176,782,336 |
|
|
72
|
+
| Light | `baseh_light_v1()` | 31 | 1 | 887,503,681 |
|
|
73
|
+
| Medium | `baseh_medium_v1()` | 28 | 1 | 481,890,304 |
|
|
74
|
+
| Heavy | `baseh_heavy_v1()` | 26 | 1 | 308,915,776 |
|
|
75
|
+
|
|
76
|
+
Medium is the default. All four keep the typed O/I/L aliases where possible
|
|
77
|
+
and run the default profanity blocklist. Minimum also uses a hyphen
|
|
78
|
+
delimiter (`XXX-XXX`); the rest have none.
|
|
79
|
+
|
|
80
|
+
Each helper returns a freshly-built mutable profile dict on every call, so
|
|
81
|
+
callers can load a default and modify it:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
profile = baseh_medium_v1()
|
|
85
|
+
profile["checksumLength"] = 2
|
|
86
|
+
codec = Baseh(profile)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Permuted variants
|
|
90
|
+
|
|
91
|
+
The `_p` variants are identical to their tier but enable feistel-v1
|
|
92
|
+
permutation and require caller-supplied key material:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from baseh import Baseh, baseh_medium_p_v1
|
|
96
|
+
|
|
97
|
+
key = bytes.fromhex("746573742d6f6e6c792d6b65792d6d6174657269616c2d30303031")
|
|
98
|
+
codec = Baseh(baseh_medium_p_v1(key, key_id="my-app-01")) # rounds defaults to 8
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Available as `baseh_minimum_p_v1`, `baseh_light_p_v1`, `baseh_medium_p_v1`
|
|
102
|
+
and `baseh_heavy_p_v1`.
|
|
103
|
+
|
|
104
|
+
## Profanity safety (spec 18)
|
|
105
|
+
|
|
106
|
+
All frozen tiers run the default blocklist. Profiles accept a `profanity`
|
|
107
|
+
field to change that:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
# Block specific words (substrings of the raw code) at encode time:
|
|
111
|
+
profile = baseh_medium_v1()
|
|
112
|
+
profile["profanity"] = {"mode": "blocklist", "extraWords": ["QQQQ"]}
|
|
113
|
+
codec = Baseh(profile)
|
|
114
|
+
# codec.encode(id) raises BasehError(code="BLOCKED_CODE") on a match.
|
|
115
|
+
|
|
116
|
+
# Or remove vowels from both alphabets entirely:
|
|
117
|
+
profile["profanity"] = {"mode": "no-vowels"}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cd python
|
|
124
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
125
|
+
```
|
baseh-1.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# baseh
|
|
2
|
+
|
|
3
|
+
Python implementation of the baseH codec. Encodes an internal integer ID as
|
|
4
|
+
a checksummed, optionally permuted human-readable reference code. Implements
|
|
5
|
+
the normative spec in `../spec/IMPLEMENTATION_CODEC.md` and passes the
|
|
6
|
+
frozen cross-language vectors in `../vectors/`.
|
|
7
|
+
|
|
8
|
+
Zero runtime dependencies. HMAC-SHA-256 comes from the standard library.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install ./python
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or run in place without installing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
PYTHONPATH=python/src python3 -c "import baseh"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from baseh import Baseh, BasehError, baseh_medium_v1
|
|
26
|
+
|
|
27
|
+
codec = Baseh(baseh_medium_v1())
|
|
28
|
+
|
|
29
|
+
code = codec.encode(123456789)
|
|
30
|
+
print(code) # fixed-length, checksummed code
|
|
31
|
+
|
|
32
|
+
result = codec.decode(code.lower()) # case-insensitive
|
|
33
|
+
print(result.id) # 123456789
|
|
34
|
+
print(result.canonical_code) # canonical rendering
|
|
35
|
+
print(result.corrected) # False (input differed only in case)
|
|
36
|
+
|
|
37
|
+
# Assisted correction over spoken-confusion pairs (B/D, P/T, ...):
|
|
38
|
+
fixed = codec.decode(
|
|
39
|
+
code,
|
|
40
|
+
try_correction=True,
|
|
41
|
+
confusion_profile="light",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Non-throwing validation for user input:
|
|
45
|
+
check = codec.validate("0000000")
|
|
46
|
+
print(check) # {"valid": False, "reason": "INVALID_CHECKSUM"}
|
|
47
|
+
|
|
48
|
+
print(codec.capacity()) # 481890304
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Errors raise `BasehError` with a `.code` attribute, one of:
|
|
52
|
+
`INVALID_PROFILE`, `OUT_OF_RANGE`, `PERMUTATION_FAILURE`, `INVALID_LENGTH`,
|
|
53
|
+
`INVALID_CHARACTER`, `INVALID_CHECKSUM`, `AMBIGUOUS_INPUT`,
|
|
54
|
+
`TOO_MANY_CANDIDATES`, `BLOCKED_CODE`.
|
|
55
|
+
|
|
56
|
+
## Frozen tiers
|
|
57
|
+
|
|
58
|
+
Four frozen profiles, each 6 body symbols and case-insensitive, built from
|
|
59
|
+
the full alphanumeric set with cumulative visual and spoken strips:
|
|
60
|
+
|
|
61
|
+
| Tier | Helper | Symbols | Checksum | Capacity |
|
|
62
|
+
|------|--------|---------|----------|----------|
|
|
63
|
+
| Minimum | `baseh_minimum_v1()` | 36 | none | 2,176,782,336 |
|
|
64
|
+
| Light | `baseh_light_v1()` | 31 | 1 | 887,503,681 |
|
|
65
|
+
| Medium | `baseh_medium_v1()` | 28 | 1 | 481,890,304 |
|
|
66
|
+
| Heavy | `baseh_heavy_v1()` | 26 | 1 | 308,915,776 |
|
|
67
|
+
|
|
68
|
+
Medium is the default. All four keep the typed O/I/L aliases where possible
|
|
69
|
+
and run the default profanity blocklist. Minimum also uses a hyphen
|
|
70
|
+
delimiter (`XXX-XXX`); the rest have none.
|
|
71
|
+
|
|
72
|
+
Each helper returns a freshly-built mutable profile dict on every call, so
|
|
73
|
+
callers can load a default and modify it:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
profile = baseh_medium_v1()
|
|
77
|
+
profile["checksumLength"] = 2
|
|
78
|
+
codec = Baseh(profile)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Permuted variants
|
|
82
|
+
|
|
83
|
+
The `_p` variants are identical to their tier but enable feistel-v1
|
|
84
|
+
permutation and require caller-supplied key material:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from baseh import Baseh, baseh_medium_p_v1
|
|
88
|
+
|
|
89
|
+
key = bytes.fromhex("746573742d6f6e6c792d6b65792d6d6174657269616c2d30303031")
|
|
90
|
+
codec = Baseh(baseh_medium_p_v1(key, key_id="my-app-01")) # rounds defaults to 8
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Available as `baseh_minimum_p_v1`, `baseh_light_p_v1`, `baseh_medium_p_v1`
|
|
94
|
+
and `baseh_heavy_p_v1`.
|
|
95
|
+
|
|
96
|
+
## Profanity safety (spec 18)
|
|
97
|
+
|
|
98
|
+
All frozen tiers run the default blocklist. Profiles accept a `profanity`
|
|
99
|
+
field to change that:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Block specific words (substrings of the raw code) at encode time:
|
|
103
|
+
profile = baseh_medium_v1()
|
|
104
|
+
profile["profanity"] = {"mode": "blocklist", "extraWords": ["QQQQ"]}
|
|
105
|
+
codec = Baseh(profile)
|
|
106
|
+
# codec.encode(id) raises BasehError(code="BLOCKED_CODE") on a match.
|
|
107
|
+
|
|
108
|
+
# Or remove vowels from both alphabets entirely:
|
|
109
|
+
profile["profanity"] = {"mode": "no-vowels"}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Tests
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cd python
|
|
116
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
117
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "baseh"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "baseH codec: checksumed, optionally permuted human-readable identifiers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "AGPL-3.0-only" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[tool.setuptools]
|
|
15
|
+
package-dir = { "" = "src" }
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
baseh-1.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""baseH codec, Python implementation.
|
|
2
|
+
|
|
3
|
+
Public API mirrors spec section 12: the Baseh codec class, BasehError with a
|
|
4
|
+
stable .code attribute and the frozen profile tier helpers (baseh_medium_v1
|
|
5
|
+
is the default tier; the _p variants enable feistel-v1 permutation).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .blocklist import DEFAULT_BLOCKLIST
|
|
9
|
+
from .codec import CONFUSION_MAPS, Baseh, DecodeResult, generate_candidates
|
|
10
|
+
from .errors import (
|
|
11
|
+
AMBIGUOUS_INPUT,
|
|
12
|
+
BLOCKED_CODE,
|
|
13
|
+
INVALID_CHARACTER,
|
|
14
|
+
INVALID_CHECKSUM,
|
|
15
|
+
INVALID_LENGTH,
|
|
16
|
+
INVALID_PROFILE,
|
|
17
|
+
OUT_OF_RANGE,
|
|
18
|
+
PERMUTATION_FAILURE,
|
|
19
|
+
TOO_MANY_CANDIDATES,
|
|
20
|
+
BasehError,
|
|
21
|
+
)
|
|
22
|
+
from .profiles import (
|
|
23
|
+
baseh_heavy_p_v1,
|
|
24
|
+
baseh_heavy_v1,
|
|
25
|
+
baseh_light_p_v1,
|
|
26
|
+
baseh_light_v1,
|
|
27
|
+
baseh_medium_p_v1,
|
|
28
|
+
baseh_medium_v1,
|
|
29
|
+
baseh_minimum_p_v1,
|
|
30
|
+
baseh_minimum_v1,
|
|
31
|
+
)
|
|
32
|
+
from .zero import from_code, to_code
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Baseh",
|
|
36
|
+
"BasehError",
|
|
37
|
+
"DecodeResult",
|
|
38
|
+
"CONFUSION_MAPS",
|
|
39
|
+
"DEFAULT_BLOCKLIST",
|
|
40
|
+
"generate_candidates",
|
|
41
|
+
"baseh_minimum_v1",
|
|
42
|
+
"baseh_minimum_p_v1",
|
|
43
|
+
"baseh_light_v1",
|
|
44
|
+
"baseh_light_p_v1",
|
|
45
|
+
"baseh_medium_v1",
|
|
46
|
+
"baseh_medium_p_v1",
|
|
47
|
+
"baseh_heavy_v1",
|
|
48
|
+
"baseh_heavy_p_v1",
|
|
49
|
+
"INVALID_PROFILE",
|
|
50
|
+
"OUT_OF_RANGE",
|
|
51
|
+
"PERMUTATION_FAILURE",
|
|
52
|
+
"INVALID_LENGTH",
|
|
53
|
+
"INVALID_CHARACTER",
|
|
54
|
+
"INVALID_CHECKSUM",
|
|
55
|
+
"AMBIGUOUS_INPUT",
|
|
56
|
+
"TOO_MANY_CANDIDATES",
|
|
57
|
+
"BLOCKED_CODE",
|
|
58
|
+
"to_code",
|
|
59
|
+
"from_code",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Fixed-length base-N encode and decode, spec sections 5.1 through 5.3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import INVALID_CHARACTER, OUT_OF_RANGE, BasehError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def alphabet_index(alphabet: str) -> dict:
|
|
9
|
+
return {ch: i for i, ch in enumerate(alphabet)}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode_base_n(value: int, alphabet: str, length: int) -> str:
|
|
13
|
+
"""Fixed-length base-N encode, most significant digit first."""
|
|
14
|
+
base = len(alphabet)
|
|
15
|
+
capacity = base ** length
|
|
16
|
+
if value < 0 or value >= capacity:
|
|
17
|
+
raise BasehError(OUT_OF_RANGE, "value is outside the fixed-length capacity")
|
|
18
|
+
out = [""] * length
|
|
19
|
+
v = value
|
|
20
|
+
for pos in range(length - 1, -1, -1):
|
|
21
|
+
out[pos] = alphabet[v % base]
|
|
22
|
+
v //= base
|
|
23
|
+
return "".join(out)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decode_base_n(text: str, alphabet: str, index: dict | None = None) -> int:
|
|
27
|
+
base = len(alphabet)
|
|
28
|
+
if index is None:
|
|
29
|
+
index = alphabet_index(alphabet)
|
|
30
|
+
value = 0
|
|
31
|
+
for ch in text:
|
|
32
|
+
digit = index.get(ch)
|
|
33
|
+
if digit is None:
|
|
34
|
+
raise BasehError(INVALID_CHARACTER, f"Symbol {ch!r} is not in the alphabet")
|
|
35
|
+
value = value * base + digit
|
|
36
|
+
return value
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Profanity safety primitives, spec section 18."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .errors import INVALID_PROFILE, BasehError
|
|
8
|
+
|
|
9
|
+
# Spec 18.2 default list. Deliberately small; applications extend it.
|
|
10
|
+
DEFAULT_BLOCKLIST = (
|
|
11
|
+
"CRAP", "TWAT", "SHAG", "DAMN", "FCK", "FUC",
|
|
12
|
+
"SHT", "CNT", "TWT", "DCK", "AZZ", "BCH",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_WORD = re.compile(r"^[A-Za-z]{2,32}$")
|
|
16
|
+
_VOWELS = frozenset("AEIOU")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def effective_blocklist(profanity: dict) -> list:
|
|
20
|
+
"""Spec 18.2: replacement semantics, then augmentation, uppercased and
|
|
21
|
+
deduplicated. Raises BasehError INVALID_PROFILE on a malformed entry."""
|
|
22
|
+
base = list(profanity["words"]) if "words" in profanity else list(DEFAULT_BLOCKLIST)
|
|
23
|
+
words = base + list(profanity.get("extraWords") or [])
|
|
24
|
+
out: list = []
|
|
25
|
+
for word in words:
|
|
26
|
+
if not isinstance(word, str) or not _WORD.match(word):
|
|
27
|
+
raise BasehError(
|
|
28
|
+
INVALID_PROFILE,
|
|
29
|
+
"Invalid baseH profile: blocklist entries must be 2 through 32 ASCII letters",
|
|
30
|
+
False,
|
|
31
|
+
)
|
|
32
|
+
upper = word.upper()
|
|
33
|
+
if upper not in out:
|
|
34
|
+
out.append(upper)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def strip_vowels(alphabet_norm: str) -> str:
|
|
39
|
+
"""Spec 18.1: vowels removed for no-vowels mode, applied after case
|
|
40
|
+
normalization."""
|
|
41
|
+
return "".join(ch for ch in alphabet_norm if ch not in _VOWELS)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Version 1 rolling polynomial checksum, spec section 6.2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .basen import alphabet_index, encode_base_n
|
|
6
|
+
from .errors import INVALID_CHARACTER, BasehError
|
|
7
|
+
from .profile import PreparedProfile
|
|
8
|
+
|
|
9
|
+
_INITIAL_STATE = 17
|
|
10
|
+
_MULTIPLIER = 37
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def checksum_value(profile: PreparedProfile, body: str, body_index: dict) -> int:
|
|
14
|
+
"""Return the checksum value in [0, modulus)."""
|
|
15
|
+
modulus = profile.checksum_modulus
|
|
16
|
+
state = _INITIAL_STATE
|
|
17
|
+
for byte in profile.profile_id.encode("ascii"):
|
|
18
|
+
state = (state * _MULTIPLIER + byte + 1) % modulus
|
|
19
|
+
state = (state * _MULTIPLIER) % modulus
|
|
20
|
+
for pos, ch in enumerate(body):
|
|
21
|
+
value = body_index.get(ch)
|
|
22
|
+
if value is None:
|
|
23
|
+
raise BasehError(
|
|
24
|
+
INVALID_CHARACTER, f"Symbol {ch!r} is not in the body alphabet"
|
|
25
|
+
)
|
|
26
|
+
state = (state * _MULTIPLIER + value + pos + 1) % modulus
|
|
27
|
+
return state
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def calculate_checksum(profile: PreparedProfile, body: str) -> str:
|
|
31
|
+
"""Compute the expected checksum string for a normalized body."""
|
|
32
|
+
if profile.checksum_length == 0:
|
|
33
|
+
return ""
|
|
34
|
+
index = alphabet_index(profile.body_alphabet_norm)
|
|
35
|
+
value = checksum_value(profile, body, index)
|
|
36
|
+
return encode_base_n(value, profile.checksum_alphabet_norm, profile.checksum_length)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Full encode and decode flows, spec sections 8, 9, 10, 11 and 12."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .basen import alphabet_index, decode_base_n, encode_base_n
|
|
8
|
+
from .checksum import calculate_checksum
|
|
9
|
+
from .errors import (
|
|
10
|
+
AMBIGUOUS_INPUT,
|
|
11
|
+
BLOCKED_CODE,
|
|
12
|
+
INVALID_CHARACTER,
|
|
13
|
+
INVALID_CHECKSUM,
|
|
14
|
+
INVALID_LENGTH,
|
|
15
|
+
OUT_OF_RANGE,
|
|
16
|
+
TOO_MANY_CANDIDATES,
|
|
17
|
+
BasehError,
|
|
18
|
+
)
|
|
19
|
+
from .feistel import FeistelKey, inverse_permute, permute
|
|
20
|
+
from .profile import PreparedProfile, prepare_profile
|
|
21
|
+
|
|
22
|
+
# Built-in spoken-confusion candidate maps, spec 3.3. Body symbols only.
|
|
23
|
+
CONFUSION_MAPS = {
|
|
24
|
+
"light": {"B": ["D"], "D": ["B"], "P": ["T"], "T": ["P"]},
|
|
25
|
+
"medium": {
|
|
26
|
+
"B": ["D"], "D": ["B"], "P": ["T"], "T": ["P"],
|
|
27
|
+
"M": ["N"], "N": ["M"], "V": ["W"], "W": ["V"],
|
|
28
|
+
},
|
|
29
|
+
"heavy": {
|
|
30
|
+
"B": ["D"], "D": ["B"], "P": ["T"], "T": ["P"],
|
|
31
|
+
"M": ["N"], "N": ["M"], "V": ["W"], "W": ["V"],
|
|
32
|
+
"F": ["S"], "S": ["F"], "C": ["G"], "G": ["C"],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_ASCII_WS = "\t\n\v\f\r "
|
|
37
|
+
_MAX_CANDIDATES = 64
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DecodeResult:
|
|
42
|
+
id: int
|
|
43
|
+
canonical_code: str
|
|
44
|
+
corrected: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize(input: str, profile: PreparedProfile, accept_spaces: bool = False) -> str:
|
|
48
|
+
"""Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string."""
|
|
49
|
+
if not isinstance(input, str):
|
|
50
|
+
raise BasehError(INVALID_CHARACTER, "input must be a string")
|
|
51
|
+
s = input.strip(_ASCII_WS)
|
|
52
|
+
if profile.separator:
|
|
53
|
+
s = s.replace(profile.separator, "")
|
|
54
|
+
if accept_spaces:
|
|
55
|
+
s = s.replace(" ", "")
|
|
56
|
+
if not profile.case_sensitive:
|
|
57
|
+
s = s.upper()
|
|
58
|
+
if profile.aliases_norm:
|
|
59
|
+
s = "".join(profile.aliases_norm.get(ch, ch) for ch in s)
|
|
60
|
+
allowed = set(profile.body_alphabet_norm) | set(profile.checksum_alphabet_norm)
|
|
61
|
+
for ch in s:
|
|
62
|
+
if ch not in allowed:
|
|
63
|
+
raise BasehError(INVALID_CHARACTER, f"Symbol {ch!r} is not accepted")
|
|
64
|
+
expected = profile.body_length + profile.checksum_length
|
|
65
|
+
# Spec 3.4: a code that lost leading zero body symbols is re-padded with
|
|
66
|
+
# the body zero symbol. The checksum symbols always remain, so the split
|
|
67
|
+
# point is unambiguous. A fully stripped no-checksum code would be empty
|
|
68
|
+
# and stays a length error.
|
|
69
|
+
if len(s) < expected and len(s) >= max(profile.checksum_length, 1):
|
|
70
|
+
zero = profile.body_alphabet_norm[0]
|
|
71
|
+
s = zero * (expected - len(s)) + s
|
|
72
|
+
if len(s) != expected:
|
|
73
|
+
raise BasehError(INVALID_LENGTH, f"Expected {expected} symbols, got {len(s)}")
|
|
74
|
+
return s
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_raw(raw: str, profile: PreparedProfile) -> str:
|
|
78
|
+
if not profile.separator:
|
|
79
|
+
return raw
|
|
80
|
+
parts = []
|
|
81
|
+
offset = 0
|
|
82
|
+
for size in profile.grouping:
|
|
83
|
+
parts.append(raw[offset : offset + size])
|
|
84
|
+
offset += size
|
|
85
|
+
return profile.separator.join(parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_candidates(body: str, confusion_map: dict, max_edits: int = 1) -> list:
|
|
89
|
+
"""Spec 10. Substitution-only candidate generation, capped and deduplicated."""
|
|
90
|
+
if max_edits == 0:
|
|
91
|
+
return []
|
|
92
|
+
results: set = set()
|
|
93
|
+
for pos, source in enumerate(body):
|
|
94
|
+
for replacement in confusion_map.get(source, ()):
|
|
95
|
+
candidate = body[:pos] + replacement + body[pos + 1 :]
|
|
96
|
+
results.add(candidate)
|
|
97
|
+
if len(results) > _MAX_CANDIDATES:
|
|
98
|
+
raise BasehError(
|
|
99
|
+
TOO_MANY_CANDIDATES,
|
|
100
|
+
"Candidate generation exceeded 64 entries",
|
|
101
|
+
False,
|
|
102
|
+
)
|
|
103
|
+
return list(results)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Baseh:
|
|
107
|
+
"""Codec bound to one validated profile. The profile is validated once at
|
|
108
|
+
construction per spec 2.2, never per encode or decode."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, profile: dict) -> None:
|
|
111
|
+
if isinstance(profile, PreparedProfile):
|
|
112
|
+
self._profile = profile
|
|
113
|
+
else:
|
|
114
|
+
self._profile = prepare_profile(profile)
|
|
115
|
+
self._body_index = alphabet_index(self._profile.body_alphabet_norm)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def profile(self) -> PreparedProfile:
|
|
119
|
+
return self._profile
|
|
120
|
+
|
|
121
|
+
def capacity(self) -> int:
|
|
122
|
+
return self._profile.capacity
|
|
123
|
+
|
|
124
|
+
def _feistel_key(self) -> FeistelKey:
|
|
125
|
+
perm = self._profile.permutation
|
|
126
|
+
return FeistelKey(
|
|
127
|
+
profile_id=self._profile.profile_id,
|
|
128
|
+
key_bytes=perm.key_bytes,
|
|
129
|
+
rounds=perm.rounds,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def encode(self, id: int) -> str:
|
|
133
|
+
"""Spec 8, including the section 18.2 blocked-substring scan."""
|
|
134
|
+
if isinstance(id, bool) or not isinstance(id, int):
|
|
135
|
+
raise BasehError(OUT_OF_RANGE, "id must be an integer")
|
|
136
|
+
value = id
|
|
137
|
+
if value < 0 or value >= self._profile.capacity:
|
|
138
|
+
raise BasehError(OUT_OF_RANGE, f"ID {value} is outside the profile capacity")
|
|
139
|
+
if self._profile.permutation.enabled:
|
|
140
|
+
value = permute(value, self._profile.capacity, self._feistel_key())
|
|
141
|
+
body = encode_base_n(
|
|
142
|
+
value, self._profile.body_alphabet_norm, self._profile.body_length
|
|
143
|
+
)
|
|
144
|
+
checksum = calculate_checksum(self._profile, body)
|
|
145
|
+
raw = body + checksum
|
|
146
|
+
# Spec 18.2: case-insensitive substring scan over the raw code.
|
|
147
|
+
if self._profile.blocklist:
|
|
148
|
+
upper = raw.upper()
|
|
149
|
+
if any(word in upper for word in self._profile.blocklist):
|
|
150
|
+
raise BasehError(
|
|
151
|
+
BLOCKED_CODE,
|
|
152
|
+
"The generated reference contains a blocked substring",
|
|
153
|
+
False,
|
|
154
|
+
)
|
|
155
|
+
return format_raw(raw, self._profile)
|
|
156
|
+
|
|
157
|
+
def decode(
|
|
158
|
+
self,
|
|
159
|
+
input: str,
|
|
160
|
+
*,
|
|
161
|
+
accept_spaces: bool = False,
|
|
162
|
+
try_correction: bool = False,
|
|
163
|
+
confusion_profile: str = "none",
|
|
164
|
+
max_corrections: int = 1,
|
|
165
|
+
) -> DecodeResult:
|
|
166
|
+
"""Spec 9."""
|
|
167
|
+
raw = normalize(input, self._profile, accept_spaces)
|
|
168
|
+
body = raw[: self._profile.body_length]
|
|
169
|
+
supplied_checksum = raw[self._profile.body_length :]
|
|
170
|
+
|
|
171
|
+
# Spec 3.1 validates union membership before the split. There is no
|
|
172
|
+
# per-region membership check: a checksum-region symbol outside the
|
|
173
|
+
# checksum alphabet fails as INVALID_CHECKSUM and a body symbol
|
|
174
|
+
# outside the body alphabet fails in the checksum or base-N work as
|
|
175
|
+
# INVALID_CHARACTER.
|
|
176
|
+
|
|
177
|
+
if calculate_checksum(self._profile, body) != supplied_checksum:
|
|
178
|
+
if not try_correction or max_corrections == 0:
|
|
179
|
+
raise BasehError(
|
|
180
|
+
INVALID_CHECKSUM, "The reference code did not pass validation"
|
|
181
|
+
)
|
|
182
|
+
if confusion_profile == "none":
|
|
183
|
+
raw_map: dict = {}
|
|
184
|
+
elif confusion_profile in CONFUSION_MAPS:
|
|
185
|
+
raw_map = CONFUSION_MAPS[confusion_profile]
|
|
186
|
+
else:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"unknown confusion profile: {confusion_profile!r}"
|
|
189
|
+
)
|
|
190
|
+
# Spec 10: replacements that are not body alphabet symbols are
|
|
191
|
+
# dropped before candidate generation. A suggested symbol the
|
|
192
|
+
# alphabet cannot contain (say a spoken drop on a stripped-alphabet
|
|
193
|
+
# profile) could never validate; generating it anyway would raise
|
|
194
|
+
# INVALID_CHARACTER from the checksum step instead of reporting an
|
|
195
|
+
# honest INVALID_CHECKSUM.
|
|
196
|
+
body_set = set(self._profile.body_alphabet_norm)
|
|
197
|
+
confusion_map = {}
|
|
198
|
+
for source, replacements in raw_map.items():
|
|
199
|
+
kept = [r for r in replacements if r in body_set]
|
|
200
|
+
if kept:
|
|
201
|
+
confusion_map[source] = kept
|
|
202
|
+
valid: set = set()
|
|
203
|
+
for candidate in generate_candidates(body, confusion_map, max_corrections):
|
|
204
|
+
if calculate_checksum(self._profile, candidate) == supplied_checksum:
|
|
205
|
+
valid.add(candidate)
|
|
206
|
+
if not valid:
|
|
207
|
+
raise BasehError(
|
|
208
|
+
INVALID_CHECKSUM, "The reference code did not pass validation"
|
|
209
|
+
)
|
|
210
|
+
if len(valid) > 1:
|
|
211
|
+
raise BasehError(
|
|
212
|
+
AMBIGUOUS_INPUT,
|
|
213
|
+
"The reference code matches more than one record",
|
|
214
|
+
False,
|
|
215
|
+
)
|
|
216
|
+
body = next(iter(valid))
|
|
217
|
+
|
|
218
|
+
value = decode_base_n(
|
|
219
|
+
body, self._profile.body_alphabet_norm, self._body_index
|
|
220
|
+
)
|
|
221
|
+
if self._profile.permutation.enabled:
|
|
222
|
+
value = inverse_permute(
|
|
223
|
+
value, self._profile.capacity, self._feistel_key()
|
|
224
|
+
)
|
|
225
|
+
canonical_code = self.encode(value)
|
|
226
|
+
if self._profile.separator:
|
|
227
|
+
canonical_raw = canonical_code.replace(self._profile.separator, "")
|
|
228
|
+
else:
|
|
229
|
+
canonical_raw = canonical_code
|
|
230
|
+
return DecodeResult(
|
|
231
|
+
id=value,
|
|
232
|
+
canonical_code=canonical_code,
|
|
233
|
+
corrected=(raw != canonical_raw),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def validate(self, input: str, **options) -> dict:
|
|
237
|
+
"""Spec 12.4. Never raises on user input."""
|
|
238
|
+
try:
|
|
239
|
+
result = self.decode(input, **options)
|
|
240
|
+
return {"valid": True, "canonical_code": result.canonical_code}
|
|
241
|
+
except BasehError as err:
|
|
242
|
+
return {"valid": False, "reason": err.code}
|