sdtmf 2.0.1__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.
- sdtmf-2.0.1/LICENSE +21 -0
- sdtmf-2.0.1/PKG-INFO +161 -0
- sdtmf-2.0.1/README.md +138 -0
- sdtmf-2.0.1/pyproject.toml +32 -0
- sdtmf-2.0.1/sdtmf.egg-info/PKG-INFO +161 -0
- sdtmf-2.0.1/sdtmf.egg-info/SOURCES.txt +8 -0
- sdtmf-2.0.1/sdtmf.egg-info/dependency_links.txt +1 -0
- sdtmf-2.0.1/sdtmf.egg-info/top_level.txt +1 -0
- sdtmf-2.0.1/sdtmf.py +182 -0
- sdtmf-2.0.1/setup.cfg +4 -0
sdtmf-2.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nanda Winata
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sdtmf-2.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sdtmf
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Authenticated, replay-protected encryption for DTMF tone frames (SDTMF v2)
|
|
5
|
+
Author: Nanda Winata
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ndawinata/SDTMF
|
|
8
|
+
Project-URL: Specification, https://github.com/ndawinata/SDTMF/blob/main/SPEC.md
|
|
9
|
+
Project-URL: Issues, https://github.com/ndawinata/SDTMF/issues
|
|
10
|
+
Project-URL: DOI, https://doi.org/10.5281/zenodo.23637663
|
|
11
|
+
Keywords: dtmf,encryption,hmac,authentication,mt8870,embedded
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# sdtmf
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/sdtmf/)
|
|
27
|
+
[](https://doi.org/10.5281/zenodo.23637663)
|
|
28
|
+
|
|
29
|
+
Authenticated, replay-protected encryption for DTMF tone frames (SDTMF v2).
|
|
30
|
+
|
|
31
|
+
A sender turns a short message (`0-9A-D`) into a tone string such as
|
|
32
|
+
`*015D0857C1CB#`. A receiver with the same key recovers the message and rejects
|
|
33
|
+
replays, wrong keys, and flipped digits. The output uses only standard DTMF
|
|
34
|
+
keys, so any tone generator or decoder (e.g. MT8870) can carry it.
|
|
35
|
+
|
|
36
|
+
Frames are byte-for-byte compatible with the JavaScript, C/Arduino, and Go
|
|
37
|
+
implementations in the [SDTMF repository](https://github.com/ndawinata/SDTMF).
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install sdtmf
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.8+, standard library only.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sdtmf import SecureDTMF
|
|
51
|
+
|
|
52
|
+
tx = SecureDTMF("secret-key") # sender
|
|
53
|
+
rx = SecureDTMF("secret-key") # receiver
|
|
54
|
+
|
|
55
|
+
frame = tx.encrypt("1234") # '*015D0857C1CB#'
|
|
56
|
+
rx.decrypt(frame) # '1234'
|
|
57
|
+
rx.decrypt(frame) # None (replay rejected)
|
|
58
|
+
|
|
59
|
+
nxt = tx.encrypt("1234") # '*0275D6549036#' (different every time)
|
|
60
|
+
rx.decrypt(nxt) # '1234'
|
|
61
|
+
|
|
62
|
+
SecureDTMF("wrong-key").decrypt(nxt) # None (wrong key)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`decrypt` returns `None` for any frame that is forged, modified, replayed,
|
|
66
|
+
malformed, or encrypted with a different key. `encrypt` raises `ValueError` if
|
|
67
|
+
the message contains characters outside `0-9 A-D`.
|
|
68
|
+
|
|
69
|
+
## Frame format
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
start counter ciphertext tag end
|
|
73
|
+
* 01 5D08 57C1CB # -> *015D0857C1CB#
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Frame length = `start + ctr_tx + message length + tag_len + end`.
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
custom = SecureDTMF(
|
|
82
|
+
"secret-key",
|
|
83
|
+
ctr_tx=1, # counter symbols per frame, 0..8 (default 2)
|
|
84
|
+
tag_len=4, # tag symbols per frame, 1..8 (default 6)
|
|
85
|
+
start="#", # '' or one char outside 0-9 A-D (default '*')
|
|
86
|
+
end="*", # '' or one char outside 0-9 A-D (default '#')
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
custom.encrypt("1234") # '#15D08C1CB*'
|
|
90
|
+
custom.frame_len(4) # 11
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Sender and receiver must use the same options.** They are not sent in the
|
|
94
|
+
frame; a mismatch makes every frame fail to decrypt.
|
|
95
|
+
|
|
96
|
+
| `ctr_tx` / `tag_len` | Tones for a 4-char message | Forgery chance per attempt | Lost frames tolerated |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| 2 / 6 (default) | 14 | 1 in 7,529,536 | 195 |
|
|
99
|
+
| 2 / 8 | 16 | 1 in 1,475,789,056 | 195 |
|
|
100
|
+
| 2 / 4 | 12 | 1 in 38,416 | 195 |
|
|
101
|
+
| 1 / 4 | 11 | 1 in 38,416 | 13 |
|
|
102
|
+
| 0 / 4 | 10 | 1 in 38,416 | 0 |
|
|
103
|
+
|
|
104
|
+
## Persisting state
|
|
105
|
+
|
|
106
|
+
Each side keeps a counter. Save it after every `encrypt` / successful
|
|
107
|
+
`decrypt` and restore it on startup; otherwise old frames could be replayed
|
|
108
|
+
and the keystream would repeat.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
state = tx.get_state() # {'tx_ctr': 2, 'rx_last': -1}
|
|
112
|
+
# ...store state, then after a restart:
|
|
113
|
+
tx.set_state(state["tx_ctr"], state["rx_last"])
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Raw keys
|
|
117
|
+
|
|
118
|
+
PBKDF2 (100,000 iterations) runs once in the constructor. To skip it, or to
|
|
119
|
+
share keys with a microcontroller, derive the two 32-byte keys once and reuse
|
|
120
|
+
them:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from sdtmf import SecureDTMF, derive_keys
|
|
124
|
+
|
|
125
|
+
k_enc, k_mac = derive_keys("secret-key")
|
|
126
|
+
fast = SecureDTMF.from_keys(k_enc, k_mac, tag_len=8)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## API
|
|
130
|
+
|
|
131
|
+
| Member | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `SecureDTMF(key, ctr_tx=2, tag_len=6, start="*", end="#")` | Create from a password. |
|
|
134
|
+
| `SecureDTMF.from_keys(k_enc, k_mac, ...)` | Create from two 32-byte keys. |
|
|
135
|
+
| `encrypt(msg)` | Return the next frame for `msg`. |
|
|
136
|
+
| `decrypt(frame)` | Return the message, or `None` if the frame is rejected. |
|
|
137
|
+
| `frame_len(n)` | Frame length for an `n`-symbol message. |
|
|
138
|
+
| `get_state()` / `set_state(tx_ctr, rx_last)` | Read / restore the counters. |
|
|
139
|
+
| `derive_keys(password)` | Return `(k_enc, k_mac)`. |
|
|
140
|
+
| `ALPH`, `DEFAULT_*` | Payload alphabet and default options. |
|
|
141
|
+
|
|
142
|
+
## Security notes
|
|
143
|
+
|
|
144
|
+
- Built from standard primitives (PBKDF2-HMAC-SHA256, HMAC-SHA256, counter),
|
|
145
|
+
but the construction has **not been independently audited**.
|
|
146
|
+
- Message length is visible from the frame length.
|
|
147
|
+
- Never share one key between two senders.
|
|
148
|
+
- Rate-limit failed frames on the receiver, especially with `tag_len` ≤ 4.
|
|
149
|
+
- Prefer a random 32-byte key over a short password.
|
|
150
|
+
- Encryption is not permitted on amateur radio bands.
|
|
151
|
+
|
|
152
|
+
## Links
|
|
153
|
+
|
|
154
|
+
- Specification: [SPEC.md](https://github.com/ndawinata/SDTMF/blob/main/SPEC.md)
|
|
155
|
+
- Test vectors: [test-vectors.json](https://github.com/ndawinata/SDTMF/blob/main/test-vectors.json)
|
|
156
|
+
- Other languages and full docs: [github.com/ndawinata/SDTMF](https://github.com/ndawinata/SDTMF)
|
|
157
|
+
- Cite: [10.5281/zenodo.23637663](https://doi.org/10.5281/zenodo.23637663)
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
sdtmf-2.0.1/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# sdtmf
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/sdtmf/)
|
|
4
|
+
[](https://doi.org/10.5281/zenodo.23637663)
|
|
5
|
+
|
|
6
|
+
Authenticated, replay-protected encryption for DTMF tone frames (SDTMF v2).
|
|
7
|
+
|
|
8
|
+
A sender turns a short message (`0-9A-D`) into a tone string such as
|
|
9
|
+
`*015D0857C1CB#`. A receiver with the same key recovers the message and rejects
|
|
10
|
+
replays, wrong keys, and flipped digits. The output uses only standard DTMF
|
|
11
|
+
keys, so any tone generator or decoder (e.g. MT8870) can carry it.
|
|
12
|
+
|
|
13
|
+
Frames are byte-for-byte compatible with the JavaScript, C/Arduino, and Go
|
|
14
|
+
implementations in the [SDTMF repository](https://github.com/ndawinata/SDTMF).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install sdtmf
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.8+, standard library only.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from sdtmf import SecureDTMF
|
|
28
|
+
|
|
29
|
+
tx = SecureDTMF("secret-key") # sender
|
|
30
|
+
rx = SecureDTMF("secret-key") # receiver
|
|
31
|
+
|
|
32
|
+
frame = tx.encrypt("1234") # '*015D0857C1CB#'
|
|
33
|
+
rx.decrypt(frame) # '1234'
|
|
34
|
+
rx.decrypt(frame) # None (replay rejected)
|
|
35
|
+
|
|
36
|
+
nxt = tx.encrypt("1234") # '*0275D6549036#' (different every time)
|
|
37
|
+
rx.decrypt(nxt) # '1234'
|
|
38
|
+
|
|
39
|
+
SecureDTMF("wrong-key").decrypt(nxt) # None (wrong key)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`decrypt` returns `None` for any frame that is forged, modified, replayed,
|
|
43
|
+
malformed, or encrypted with a different key. `encrypt` raises `ValueError` if
|
|
44
|
+
the message contains characters outside `0-9 A-D`.
|
|
45
|
+
|
|
46
|
+
## Frame format
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
start counter ciphertext tag end
|
|
50
|
+
* 01 5D08 57C1CB # -> *015D0857C1CB#
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Frame length = `start + ctr_tx + message length + tag_len + end`.
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
custom = SecureDTMF(
|
|
59
|
+
"secret-key",
|
|
60
|
+
ctr_tx=1, # counter symbols per frame, 0..8 (default 2)
|
|
61
|
+
tag_len=4, # tag symbols per frame, 1..8 (default 6)
|
|
62
|
+
start="#", # '' or one char outside 0-9 A-D (default '*')
|
|
63
|
+
end="*", # '' or one char outside 0-9 A-D (default '#')
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
custom.encrypt("1234") # '#15D08C1CB*'
|
|
67
|
+
custom.frame_len(4) # 11
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Sender and receiver must use the same options.** They are not sent in the
|
|
71
|
+
frame; a mismatch makes every frame fail to decrypt.
|
|
72
|
+
|
|
73
|
+
| `ctr_tx` / `tag_len` | Tones for a 4-char message | Forgery chance per attempt | Lost frames tolerated |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| 2 / 6 (default) | 14 | 1 in 7,529,536 | 195 |
|
|
76
|
+
| 2 / 8 | 16 | 1 in 1,475,789,056 | 195 |
|
|
77
|
+
| 2 / 4 | 12 | 1 in 38,416 | 195 |
|
|
78
|
+
| 1 / 4 | 11 | 1 in 38,416 | 13 |
|
|
79
|
+
| 0 / 4 | 10 | 1 in 38,416 | 0 |
|
|
80
|
+
|
|
81
|
+
## Persisting state
|
|
82
|
+
|
|
83
|
+
Each side keeps a counter. Save it after every `encrypt` / successful
|
|
84
|
+
`decrypt` and restore it on startup; otherwise old frames could be replayed
|
|
85
|
+
and the keystream would repeat.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
state = tx.get_state() # {'tx_ctr': 2, 'rx_last': -1}
|
|
89
|
+
# ...store state, then after a restart:
|
|
90
|
+
tx.set_state(state["tx_ctr"], state["rx_last"])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Raw keys
|
|
94
|
+
|
|
95
|
+
PBKDF2 (100,000 iterations) runs once in the constructor. To skip it, or to
|
|
96
|
+
share keys with a microcontroller, derive the two 32-byte keys once and reuse
|
|
97
|
+
them:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from sdtmf import SecureDTMF, derive_keys
|
|
101
|
+
|
|
102
|
+
k_enc, k_mac = derive_keys("secret-key")
|
|
103
|
+
fast = SecureDTMF.from_keys(k_enc, k_mac, tag_len=8)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## API
|
|
107
|
+
|
|
108
|
+
| Member | Description |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `SecureDTMF(key, ctr_tx=2, tag_len=6, start="*", end="#")` | Create from a password. |
|
|
111
|
+
| `SecureDTMF.from_keys(k_enc, k_mac, ...)` | Create from two 32-byte keys. |
|
|
112
|
+
| `encrypt(msg)` | Return the next frame for `msg`. |
|
|
113
|
+
| `decrypt(frame)` | Return the message, or `None` if the frame is rejected. |
|
|
114
|
+
| `frame_len(n)` | Frame length for an `n`-symbol message. |
|
|
115
|
+
| `get_state()` / `set_state(tx_ctr, rx_last)` | Read / restore the counters. |
|
|
116
|
+
| `derive_keys(password)` | Return `(k_enc, k_mac)`. |
|
|
117
|
+
| `ALPH`, `DEFAULT_*` | Payload alphabet and default options. |
|
|
118
|
+
|
|
119
|
+
## Security notes
|
|
120
|
+
|
|
121
|
+
- Built from standard primitives (PBKDF2-HMAC-SHA256, HMAC-SHA256, counter),
|
|
122
|
+
but the construction has **not been independently audited**.
|
|
123
|
+
- Message length is visible from the frame length.
|
|
124
|
+
- Never share one key between two senders.
|
|
125
|
+
- Rate-limit failed frames on the receiver, especially with `tag_len` ≤ 4.
|
|
126
|
+
- Prefer a random 32-byte key over a short password.
|
|
127
|
+
- Encryption is not permitted on amateur radio bands.
|
|
128
|
+
|
|
129
|
+
## Links
|
|
130
|
+
|
|
131
|
+
- Specification: [SPEC.md](https://github.com/ndawinata/SDTMF/blob/main/SPEC.md)
|
|
132
|
+
- Test vectors: [test-vectors.json](https://github.com/ndawinata/SDTMF/blob/main/test-vectors.json)
|
|
133
|
+
- Other languages and full docs: [github.com/ndawinata/SDTMF](https://github.com/ndawinata/SDTMF)
|
|
134
|
+
- Cite: [10.5281/zenodo.23637663](https://doi.org/10.5281/zenodo.23637663)
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sdtmf"
|
|
3
|
+
version = "2.0.1"
|
|
4
|
+
description = "Authenticated, replay-protected encryption for DTMF tone frames (SDTMF v2)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.8"
|
|
9
|
+
authors = [{ name = "Nanda Winata" }]
|
|
10
|
+
keywords = ["dtmf", "encryption", "hmac", "authentication", "mt8870", "embedded"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Communications :: Telephony",
|
|
18
|
+
"Topic :: Security :: Cryptography",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/ndawinata/SDTMF"
|
|
23
|
+
Specification = "https://github.com/ndawinata/SDTMF/blob/main/SPEC.md"
|
|
24
|
+
Issues = "https://github.com/ndawinata/SDTMF/issues"
|
|
25
|
+
DOI = "https://doi.org/10.5281/zenodo.23637663"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=77"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
py-modules = ["sdtmf"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sdtmf
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Authenticated, replay-protected encryption for DTMF tone frames (SDTMF v2)
|
|
5
|
+
Author: Nanda Winata
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ndawinata/SDTMF
|
|
8
|
+
Project-URL: Specification, https://github.com/ndawinata/SDTMF/blob/main/SPEC.md
|
|
9
|
+
Project-URL: Issues, https://github.com/ndawinata/SDTMF/issues
|
|
10
|
+
Project-URL: DOI, https://doi.org/10.5281/zenodo.23637663
|
|
11
|
+
Keywords: dtmf,encryption,hmac,authentication,mt8870,embedded
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# sdtmf
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/sdtmf/)
|
|
27
|
+
[](https://doi.org/10.5281/zenodo.23637663)
|
|
28
|
+
|
|
29
|
+
Authenticated, replay-protected encryption for DTMF tone frames (SDTMF v2).
|
|
30
|
+
|
|
31
|
+
A sender turns a short message (`0-9A-D`) into a tone string such as
|
|
32
|
+
`*015D0857C1CB#`. A receiver with the same key recovers the message and rejects
|
|
33
|
+
replays, wrong keys, and flipped digits. The output uses only standard DTMF
|
|
34
|
+
keys, so any tone generator or decoder (e.g. MT8870) can carry it.
|
|
35
|
+
|
|
36
|
+
Frames are byte-for-byte compatible with the JavaScript, C/Arduino, and Go
|
|
37
|
+
implementations in the [SDTMF repository](https://github.com/ndawinata/SDTMF).
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install sdtmf
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.8+, standard library only.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sdtmf import SecureDTMF
|
|
51
|
+
|
|
52
|
+
tx = SecureDTMF("secret-key") # sender
|
|
53
|
+
rx = SecureDTMF("secret-key") # receiver
|
|
54
|
+
|
|
55
|
+
frame = tx.encrypt("1234") # '*015D0857C1CB#'
|
|
56
|
+
rx.decrypt(frame) # '1234'
|
|
57
|
+
rx.decrypt(frame) # None (replay rejected)
|
|
58
|
+
|
|
59
|
+
nxt = tx.encrypt("1234") # '*0275D6549036#' (different every time)
|
|
60
|
+
rx.decrypt(nxt) # '1234'
|
|
61
|
+
|
|
62
|
+
SecureDTMF("wrong-key").decrypt(nxt) # None (wrong key)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`decrypt` returns `None` for any frame that is forged, modified, replayed,
|
|
66
|
+
malformed, or encrypted with a different key. `encrypt` raises `ValueError` if
|
|
67
|
+
the message contains characters outside `0-9 A-D`.
|
|
68
|
+
|
|
69
|
+
## Frame format
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
start counter ciphertext tag end
|
|
73
|
+
* 01 5D08 57C1CB # -> *015D0857C1CB#
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Frame length = `start + ctr_tx + message length + tag_len + end`.
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
custom = SecureDTMF(
|
|
82
|
+
"secret-key",
|
|
83
|
+
ctr_tx=1, # counter symbols per frame, 0..8 (default 2)
|
|
84
|
+
tag_len=4, # tag symbols per frame, 1..8 (default 6)
|
|
85
|
+
start="#", # '' or one char outside 0-9 A-D (default '*')
|
|
86
|
+
end="*", # '' or one char outside 0-9 A-D (default '#')
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
custom.encrypt("1234") # '#15D08C1CB*'
|
|
90
|
+
custom.frame_len(4) # 11
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Sender and receiver must use the same options.** They are not sent in the
|
|
94
|
+
frame; a mismatch makes every frame fail to decrypt.
|
|
95
|
+
|
|
96
|
+
| `ctr_tx` / `tag_len` | Tones for a 4-char message | Forgery chance per attempt | Lost frames tolerated |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| 2 / 6 (default) | 14 | 1 in 7,529,536 | 195 |
|
|
99
|
+
| 2 / 8 | 16 | 1 in 1,475,789,056 | 195 |
|
|
100
|
+
| 2 / 4 | 12 | 1 in 38,416 | 195 |
|
|
101
|
+
| 1 / 4 | 11 | 1 in 38,416 | 13 |
|
|
102
|
+
| 0 / 4 | 10 | 1 in 38,416 | 0 |
|
|
103
|
+
|
|
104
|
+
## Persisting state
|
|
105
|
+
|
|
106
|
+
Each side keeps a counter. Save it after every `encrypt` / successful
|
|
107
|
+
`decrypt` and restore it on startup; otherwise old frames could be replayed
|
|
108
|
+
and the keystream would repeat.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
state = tx.get_state() # {'tx_ctr': 2, 'rx_last': -1}
|
|
112
|
+
# ...store state, then after a restart:
|
|
113
|
+
tx.set_state(state["tx_ctr"], state["rx_last"])
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Raw keys
|
|
117
|
+
|
|
118
|
+
PBKDF2 (100,000 iterations) runs once in the constructor. To skip it, or to
|
|
119
|
+
share keys with a microcontroller, derive the two 32-byte keys once and reuse
|
|
120
|
+
them:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from sdtmf import SecureDTMF, derive_keys
|
|
124
|
+
|
|
125
|
+
k_enc, k_mac = derive_keys("secret-key")
|
|
126
|
+
fast = SecureDTMF.from_keys(k_enc, k_mac, tag_len=8)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## API
|
|
130
|
+
|
|
131
|
+
| Member | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `SecureDTMF(key, ctr_tx=2, tag_len=6, start="*", end="#")` | Create from a password. |
|
|
134
|
+
| `SecureDTMF.from_keys(k_enc, k_mac, ...)` | Create from two 32-byte keys. |
|
|
135
|
+
| `encrypt(msg)` | Return the next frame for `msg`. |
|
|
136
|
+
| `decrypt(frame)` | Return the message, or `None` if the frame is rejected. |
|
|
137
|
+
| `frame_len(n)` | Frame length for an `n`-symbol message. |
|
|
138
|
+
| `get_state()` / `set_state(tx_ctr, rx_last)` | Read / restore the counters. |
|
|
139
|
+
| `derive_keys(password)` | Return `(k_enc, k_mac)`. |
|
|
140
|
+
| `ALPH`, `DEFAULT_*` | Payload alphabet and default options. |
|
|
141
|
+
|
|
142
|
+
## Security notes
|
|
143
|
+
|
|
144
|
+
- Built from standard primitives (PBKDF2-HMAC-SHA256, HMAC-SHA256, counter),
|
|
145
|
+
but the construction has **not been independently audited**.
|
|
146
|
+
- Message length is visible from the frame length.
|
|
147
|
+
- Never share one key between two senders.
|
|
148
|
+
- Rate-limit failed frames on the receiver, especially with `tag_len` ≤ 4.
|
|
149
|
+
- Prefer a random 32-byte key over a short password.
|
|
150
|
+
- Encryption is not permitted on amateur radio bands.
|
|
151
|
+
|
|
152
|
+
## Links
|
|
153
|
+
|
|
154
|
+
- Specification: [SPEC.md](https://github.com/ndawinata/SDTMF/blob/main/SPEC.md)
|
|
155
|
+
- Test vectors: [test-vectors.json](https://github.com/ndawinata/SDTMF/blob/main/test-vectors.json)
|
|
156
|
+
- Other languages and full docs: [github.com/ndawinata/SDTMF](https://github.com/ndawinata/SDTMF)
|
|
157
|
+
- Cite: [10.5281/zenodo.23637663](https://doi.org/10.5281/zenodo.23637663)
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sdtmf
|
sdtmf-2.0.1/sdtmf.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""SDTMF v2 — Secure DTMF frames. See SPEC.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import hashlib
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
ALPH = "0123456789ABCD"
|
|
10
|
+
B = len(ALPH)
|
|
11
|
+
IDX = {c: i for i, c in enumerate(ALPH)}
|
|
12
|
+
SALT = b"SDTMF-v2"
|
|
13
|
+
ITERS = 100_000
|
|
14
|
+
|
|
15
|
+
DEFAULT_CTR_TX = 2
|
|
16
|
+
DEFAULT_TAG_LEN = 6
|
|
17
|
+
DEFAULT_START = "*"
|
|
18
|
+
DEFAULT_END = "#"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ALPH",
|
|
22
|
+
"DEFAULT_CTR_TX",
|
|
23
|
+
"DEFAULT_TAG_LEN",
|
|
24
|
+
"DEFAULT_START",
|
|
25
|
+
"DEFAULT_END",
|
|
26
|
+
"derive_keys",
|
|
27
|
+
"SecureDTMF",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _to_sym(n: int, w: int) -> str:
|
|
32
|
+
if w == 0:
|
|
33
|
+
return ""
|
|
34
|
+
chars = ["0"] * w
|
|
35
|
+
for i in range(w - 1, -1, -1):
|
|
36
|
+
n, r = divmod(n, B)
|
|
37
|
+
chars[i] = ALPH[r]
|
|
38
|
+
return "".join(chars)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_int(s: str) -> int:
|
|
42
|
+
n = 0
|
|
43
|
+
for c in s:
|
|
44
|
+
n = n * B + IDX[c]
|
|
45
|
+
return n
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_marker(m: str, name: str) -> None:
|
|
49
|
+
if not isinstance(m, str) or len(m) > 1 or (m and m in IDX):
|
|
50
|
+
raise ValueError(f"{name} must be '' or one character outside 0-9 A-D")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def derive_keys(password: str) -> tuple[bytes, bytes]:
|
|
54
|
+
master = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), SALT, ITERS)
|
|
55
|
+
k_enc = hmac.new(master, b"enc", hashlib.sha256).digest()
|
|
56
|
+
k_mac = hmac.new(master, b"mac", hashlib.sha256).digest()
|
|
57
|
+
return k_enc, k_mac
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SecureDTMF:
|
|
61
|
+
"""
|
|
62
|
+
Frame: start [ctr_tx] [ciphertext] [tag] end
|
|
63
|
+
tx_ctr and rx_last MUST be persisted across restarts.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
key: Optional[str] = None,
|
|
69
|
+
ctr_tx: int = DEFAULT_CTR_TX,
|
|
70
|
+
tag_len: int = DEFAULT_TAG_LEN,
|
|
71
|
+
k_enc: Optional[bytes] = None,
|
|
72
|
+
k_mac: Optional[bytes] = None,
|
|
73
|
+
start: str = DEFAULT_START,
|
|
74
|
+
end: str = DEFAULT_END,
|
|
75
|
+
):
|
|
76
|
+
if not (0 <= ctr_tx <= 8 and 1 <= tag_len <= 8):
|
|
77
|
+
raise ValueError("ctr_tx must be 0..8 and tag_len 1..8")
|
|
78
|
+
_check_marker(start, "start")
|
|
79
|
+
_check_marker(end, "end")
|
|
80
|
+
if start and start == end:
|
|
81
|
+
raise ValueError("start and end must differ")
|
|
82
|
+
if k_enc is not None and k_mac is not None:
|
|
83
|
+
if len(k_enc) != 32 or len(k_mac) != 32:
|
|
84
|
+
raise ValueError("k_enc and k_mac must be 32 bytes")
|
|
85
|
+
self.k_enc, self.k_mac = k_enc, k_mac
|
|
86
|
+
elif key is not None:
|
|
87
|
+
self.k_enc, self.k_mac = derive_keys(key)
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError("provide key= or k_enc= and k_mac=")
|
|
90
|
+
self.ctr_tx = ctr_tx
|
|
91
|
+
self.tag_len = tag_len
|
|
92
|
+
self.start = start
|
|
93
|
+
self.end = end
|
|
94
|
+
self.mod = B ** ctr_tx
|
|
95
|
+
self.tag_mod = B ** tag_len
|
|
96
|
+
self.tx_ctr = 0
|
|
97
|
+
self.rx_last = -1
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_keys(
|
|
101
|
+
cls,
|
|
102
|
+
k_enc: bytes,
|
|
103
|
+
k_mac: bytes,
|
|
104
|
+
ctr_tx: int = DEFAULT_CTR_TX,
|
|
105
|
+
tag_len: int = DEFAULT_TAG_LEN,
|
|
106
|
+
start: str = DEFAULT_START,
|
|
107
|
+
end: str = DEFAULT_END,
|
|
108
|
+
) -> "SecureDTMF":
|
|
109
|
+
return cls(
|
|
110
|
+
k_enc=k_enc, k_mac=k_mac, ctr_tx=ctr_tx, tag_len=tag_len, start=start, end=end
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def frame_len(self, msg_len: int) -> int:
|
|
114
|
+
return len(self.start) + self.ctr_tx + msg_len + self.tag_len + len(self.end)
|
|
115
|
+
|
|
116
|
+
def get_state(self) -> dict:
|
|
117
|
+
return {"tx_ctr": self.tx_ctr, "rx_last": self.rx_last}
|
|
118
|
+
|
|
119
|
+
def set_state(self, tx_ctr: int, rx_last: int) -> None:
|
|
120
|
+
self.tx_ctr = int(tx_ctr)
|
|
121
|
+
self.rx_last = int(rx_last)
|
|
122
|
+
|
|
123
|
+
def _ks(self, ctr: int, n: int) -> list[int]:
|
|
124
|
+
out: list[int] = []
|
|
125
|
+
blk = 0
|
|
126
|
+
while len(out) < n:
|
|
127
|
+
d = hmac.new(
|
|
128
|
+
self.k_enc,
|
|
129
|
+
ctr.to_bytes(8, "big") + blk.to_bytes(2, "big"),
|
|
130
|
+
hashlib.sha256,
|
|
131
|
+
).digest()
|
|
132
|
+
out += [b % B for b in d if b < 252]
|
|
133
|
+
blk += 1
|
|
134
|
+
return out[:n]
|
|
135
|
+
|
|
136
|
+
def _tag(self, ctr: int, ct: str) -> str:
|
|
137
|
+
d = hmac.new(
|
|
138
|
+
self.k_mac, ctr.to_bytes(8, "big") + ct.encode("ascii"), hashlib.sha256
|
|
139
|
+
).digest()
|
|
140
|
+
r = 0
|
|
141
|
+
m = self.tag_mod
|
|
142
|
+
for b in d:
|
|
143
|
+
r = (r * 256 + b) % m
|
|
144
|
+
return _to_sym(r, self.tag_len)
|
|
145
|
+
|
|
146
|
+
def encrypt(self, msg: str) -> str:
|
|
147
|
+
if not msg or any(c not in IDX for c in msg):
|
|
148
|
+
raise ValueError("message must be 1+ symbols from 0-9 A-D")
|
|
149
|
+
self.tx_ctr += 1
|
|
150
|
+
c = self.tx_ctr
|
|
151
|
+
ct = "".join(
|
|
152
|
+
ALPH[(IDX[m] + k) % B] for m, k in zip(msg, self._ks(c, len(msg)))
|
|
153
|
+
)
|
|
154
|
+
return (
|
|
155
|
+
self.start
|
|
156
|
+
+ _to_sym(c % self.mod, self.ctr_tx)
|
|
157
|
+
+ ct
|
|
158
|
+
+ self._tag(c, ct)
|
|
159
|
+
+ self.end
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def decrypt(self, frame: str) -> Optional[str]:
|
|
163
|
+
ls, le = len(self.start), len(self.end)
|
|
164
|
+
if (
|
|
165
|
+
len(frame) < ls + le
|
|
166
|
+
or not frame.startswith(self.start)
|
|
167
|
+
or not frame.endswith(self.end)
|
|
168
|
+
):
|
|
169
|
+
return None
|
|
170
|
+
s = frame[ls : len(frame) - le]
|
|
171
|
+
if any(ch not in IDX for ch in s) or len(s) <= self.ctr_tx + self.tag_len:
|
|
172
|
+
return None
|
|
173
|
+
low = _to_int(s[: self.ctr_tx])
|
|
174
|
+
ct, tag = s[self.ctr_tx : -self.tag_len], s[-self.tag_len :]
|
|
175
|
+
base = max(self.rx_last + 1, 1)
|
|
176
|
+
c = base + ((low - base) % self.mod)
|
|
177
|
+
if not hmac.compare_digest(tag, self._tag(c, ct)):
|
|
178
|
+
return None
|
|
179
|
+
self.rx_last = c
|
|
180
|
+
return "".join(
|
|
181
|
+
ALPH[(IDX[x] - k) % B] for x, k in zip(ct, self._ks(c, len(ct)))
|
|
182
|
+
)
|
sdtmf-2.0.1/setup.cfg
ADDED