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 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
+ [![PyPI](https://img.shields.io/pypi/v/sdtmf)](https://pypi.org/project/sdtmf/)
27
+ [![DOI](https://zenodo.org/badge/1432957615.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/sdtmf)](https://pypi.org/project/sdtmf/)
4
+ [![DOI](https://zenodo.org/badge/1432957615.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/sdtmf)](https://pypi.org/project/sdtmf/)
27
+ [![DOI](https://zenodo.org/badge/1432957615.svg)](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,8 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ sdtmf.py
5
+ sdtmf.egg-info/PKG-INFO
6
+ sdtmf.egg-info/SOURCES.txt
7
+ sdtmf.egg-info/dependency_links.txt
8
+ sdtmf.egg-info/top_level.txt
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+