ciplink 0.1.0__py3-none-any.whl
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.
- ciplink/__init__.py +40 -0
- ciplink/_logix_bundle_codec.py +108 -0
- ciplink/_logix_metadata_bundle.py +32 -0
- ciplink/_version.py +26 -0
- ciplink/cip/__init__.py +29 -0
- ciplink/cip/data_types.py +1227 -0
- ciplink/cip/object_library.py +191 -0
- ciplink/cip/pccc.py +170 -0
- ciplink/cip/services.py +117 -0
- ciplink/cip/status_info.py +1732 -0
- ciplink/cip_driver.py +706 -0
- ciplink/const.py +67 -0
- ciplink/custom_types.py +235 -0
- ciplink/exceptions.py +59 -0
- ciplink/logger.py +90 -0
- ciplink/logix_auth.py +196 -0
- ciplink/logix_descriptions.py +268 -0
- ciplink/logix_driver.py +2380 -0
- ciplink/logix_metadata.py +185 -0
- ciplink/map.py +114 -0
- ciplink/packets/__init__.py +63 -0
- ciplink/packets/base.py +197 -0
- ciplink/packets/cip.py +136 -0
- ciplink/packets/ethernetip.py +234 -0
- ciplink/packets/logix.py +501 -0
- ciplink/packets/util.py +250 -0
- ciplink/py.typed +0 -0
- ciplink/slc_driver.py +773 -0
- ciplink/socket_.py +86 -0
- ciplink/tag.py +49 -0
- ciplink/util.py +62 -0
- ciplink-0.1.0.dist-info/METADATA +302 -0
- ciplink-0.1.0.dist-info/RECORD +36 -0
- ciplink-0.1.0.dist-info/WHEEL +5 -0
- ciplink-0.1.0.dist-info/licenses/LICENSE +24 -0
- ciplink-0.1.0.dist-info/top_level.txt +1 -0
ciplink/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2021 Ian Ottoway <ian@ottoway.dev>
|
|
4
|
+
# Copyright (c) 2014 Agostino Ruscito <ruscito@gmail.com>
|
|
5
|
+
#
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
#
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
# copies or substantial portions of the Software.
|
|
15
|
+
#
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
# SOFTWARE.
|
|
23
|
+
#
|
|
24
|
+
|
|
25
|
+
from ._version import __version__, __version_info__
|
|
26
|
+
from .logger import *
|
|
27
|
+
from .const import *
|
|
28
|
+
from .tag import Tag
|
|
29
|
+
from .exceptions import *
|
|
30
|
+
from .cip import *
|
|
31
|
+
from .custom_types import *
|
|
32
|
+
from .cip_driver import *
|
|
33
|
+
from .logix_auth import LogixMetadataCredentials
|
|
34
|
+
from .logix_driver import *
|
|
35
|
+
from .slc_driver import *
|
|
36
|
+
|
|
37
|
+
from types import ModuleType as _ModuleType
|
|
38
|
+
|
|
39
|
+
# export everything above except the submodules, so `from ciplink import *` does not shadow map, logger, etc.
|
|
40
|
+
__all__ = [n for n, v in list(globals().items()) if not n.startswith('_') and not isinstance(v, _ModuleType)]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Local credential bundling with RFC 8439 ChaCha20-Poly1305.
|
|
2
|
+
|
|
3
|
+
Only the Python standard library is used. Embedding the reconstruction material in
|
|
4
|
+
the same package provides obfuscation, not secrecy from someone with the code.
|
|
5
|
+
This implementation is scoped to small, locally generated credential bundles.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hmac
|
|
9
|
+
import secrets
|
|
10
|
+
import struct
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_HEADER = b"LMK1"
|
|
14
|
+
_MASK = 0xFFFFFFFF
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _rotate(value, count):
|
|
18
|
+
return ((value << count) | (value >> (32 - count))) & _MASK
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _quarter(state, a, b, c, d):
|
|
22
|
+
state[a] = (state[a] + state[b]) & _MASK
|
|
23
|
+
state[d] = _rotate(state[d] ^ state[a], 16)
|
|
24
|
+
state[c] = (state[c] + state[d]) & _MASK
|
|
25
|
+
state[b] = _rotate(state[b] ^ state[c], 12)
|
|
26
|
+
state[a] = (state[a] + state[b]) & _MASK
|
|
27
|
+
state[d] = _rotate(state[d] ^ state[a], 8)
|
|
28
|
+
state[c] = (state[c] + state[d]) & _MASK
|
|
29
|
+
state[b] = _rotate(state[b] ^ state[c], 7)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _block(key, nonce, counter):
|
|
33
|
+
if not isinstance(key, bytes) or len(key) != 32:
|
|
34
|
+
raise ValueError("Bundle key must contain 32 bytes")
|
|
35
|
+
if not isinstance(nonce, bytes) or len(nonce) != 12 or not 0 <= counter <= _MASK:
|
|
36
|
+
raise ValueError("Invalid bundle nonce or counter")
|
|
37
|
+
initial = list(struct.unpack("<4I", b"expand 32-byte k"))
|
|
38
|
+
initial += list(struct.unpack("<8I", key)) + [counter] + list(struct.unpack("<3I", nonce))
|
|
39
|
+
state = initial[:]
|
|
40
|
+
for _ in range(10):
|
|
41
|
+
for indices in ((0, 4, 8, 12), (1, 5, 9, 13), (2, 6, 10, 14), (3, 7, 11, 15),
|
|
42
|
+
(0, 5, 10, 15), (1, 6, 11, 12), (2, 7, 8, 13), (3, 4, 9, 14)):
|
|
43
|
+
_quarter(state, *indices)
|
|
44
|
+
return struct.pack("<16I", *[(value + original) & _MASK for value, original in zip(state, initial)])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _crypt(key, nonce, data):
|
|
48
|
+
output = bytearray()
|
|
49
|
+
for counter, offset in enumerate(range(0, len(data), 64), 1):
|
|
50
|
+
stream = _block(key, nonce, counter)
|
|
51
|
+
output.extend(value ^ stream[index] for index, value in enumerate(data[offset:offset + 64]))
|
|
52
|
+
return bytes(output)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _poly1305(data, key):
|
|
56
|
+
if len(key) != 32:
|
|
57
|
+
raise ValueError("Invalid bundle authenticator key")
|
|
58
|
+
r = int.from_bytes(key[:16], "little") & 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF
|
|
59
|
+
s = int.from_bytes(key[16:], "little")
|
|
60
|
+
accumulator = 0
|
|
61
|
+
for offset in range(0, len(data), 16):
|
|
62
|
+
value = int.from_bytes(data[offset:offset + 16] + b"\x01", "little")
|
|
63
|
+
accumulator = (accumulator + value) * r % ((1 << 130) - 5)
|
|
64
|
+
return ((accumulator + s) % (1 << 128)).to_bytes(16, "little")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tag(key, nonce, ciphertext, associated):
|
|
68
|
+
mac_data = associated + bytes((-len(associated)) % 16)
|
|
69
|
+
mac_data += ciphertext + bytes((-len(ciphertext)) % 16)
|
|
70
|
+
mac_data += struct.pack("<QQ", len(associated), len(ciphertext))
|
|
71
|
+
return _poly1305(mac_data, _block(key, nonce, 0)[:32])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _encrypt(key, nonce, plaintext, associated):
|
|
75
|
+
ciphertext = _crypt(key, nonce, plaintext)
|
|
76
|
+
return ciphertext + _tag(key, nonce, ciphertext, associated)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decrypt(key, nonce, encrypted, associated):
|
|
80
|
+
if len(encrypted) < 16:
|
|
81
|
+
raise ValueError("Truncated credential bundle")
|
|
82
|
+
ciphertext, tag = encrypted[:-16], encrypted[-16:]
|
|
83
|
+
if not hmac.compare_digest(tag, _tag(key, nonce, ciphertext, associated)):
|
|
84
|
+
raise ValueError("Credential bundle authentication failed")
|
|
85
|
+
return _crypt(key, nonce, ciphertext)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def seal_credentials(certificate, private_key, key):
|
|
89
|
+
if len(certificate) not in (281, 285) or not 1 <= len(private_key) <= 2048:
|
|
90
|
+
raise ValueError("Unsupported credential bundle lengths")
|
|
91
|
+
# Every generated module uses a newly generated key and random nonce.
|
|
92
|
+
nonce = secrets.token_bytes(12)
|
|
93
|
+
payload = struct.pack("<HH", len(certificate), len(private_key)) + certificate + private_key
|
|
94
|
+
return _HEADER + nonce + _encrypt(key, nonce, payload, _HEADER)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def open_credentials(bundle, key):
|
|
98
|
+
if not isinstance(bundle, bytes) or not 36 <= len(bundle) <= 4096 or bundle[:4] != _HEADER:
|
|
99
|
+
raise ValueError("Unsupported encrypted credential bundle")
|
|
100
|
+
payload = _decrypt(key, bundle[4:16], bundle[16:], _HEADER)
|
|
101
|
+
if len(payload) < 4:
|
|
102
|
+
raise ValueError("Truncated credential bundle payload")
|
|
103
|
+
certificate_size, key_size = struct.unpack_from("<HH", payload)
|
|
104
|
+
if certificate_size not in (281, 285) or not 1 <= key_size <= 2048:
|
|
105
|
+
raise ValueError("Unsupported credential bundle payload lengths")
|
|
106
|
+
if len(payload) != 4 + certificate_size + key_size:
|
|
107
|
+
raise ValueError("Credential bundle payload length mismatch")
|
|
108
|
+
return payload[4:4 + certificate_size], payload[4 + certificate_size:]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Generated sealed metadata credentials; reversible local obfuscation."""
|
|
2
|
+
import base64
|
|
3
|
+
from ._logix_bundle_codec import open_credentials
|
|
4
|
+
|
|
5
|
+
_BLOCKS = (
|
|
6
|
+
'uaqeVqRwHD{(Pv`t8{p18+Ns)I{x!*bl9t_JYMB(J!4Db0yCX9w$~#rtGCaOc2D}LS6-Hc7JA5V?MTy3',
|
|
7
|
+
'WnlPK!dQ?a@Lc%_Q;;?D#yWb7(`_cz(0LHa`wOD}pi47y(bjL~qRPX{gQ<Sla`V;ESNVb4T5tP+A8@pD',
|
|
8
|
+
'Zh={^oB%8nb}HGn`Z%`(^hH6*uykD3k7(393&gMx31A4n7Qt}@rTA6_!3;n&aIB`{lWwua3*XPgFH;w&',
|
|
9
|
+
'lD4?s!V{j7V1L|*^o~*M6#(+<=&Ww#I5s5{NSDR)?X+Znb`R~H_1@KqmImMr_AtDMGW+?C-ul94W0k=W',
|
|
10
|
+
'HhqnsTL5Ja3Pc()RgO6DnjkPVw)}}L*3TsWcqWsJR$`i(Z+}e9rE2HR_Q(i`XnRDLuz=qTAVw&N`+tFG',
|
|
11
|
+
'#{rU1Xzt#6VS+T&(8haBVoad{NOiGf>6UVQuOw|&j=<i54tpAH57erNlot80>4Slr<uvK*1RyOA)$L?<',
|
|
12
|
+
'ppCpehcU8oE&z?NdliNMd_5(`G!;YVXZk+aZ3z|sXy_XW+X4qs(HmXV{Czf|LDY|;jrbXOp-GY',
|
|
13
|
+
'RfWq&wx%QCsGonx{zGhJ>>PaY<ZgK*_)Iy<H{c`fTQFK^v>1>DNA^$kD|){Y><yCaqqXWQ>-%Xu6jFPq',
|
|
14
|
+
'W|3nui(P|@bfl|YBwmy@;A*~D$!&}Wr|Q1Fcr`R*RG}`zlC{XiecE%qV9lnrz+bfb+9rOM#Y%Kk$Pt`A',
|
|
15
|
+
'1}WV_H_Qr4rsq;qLO&EWm-IxBx*PciRnkQhOM>QGqo{`qL>8N9H*?zLx=uaXBXyMv6U;CEw_oa{;0noX',
|
|
16
|
+
'5QHE~fIbVVJbZ#o56uf0ukhyxvBs{GAa0}aCqyvAY5?C!_sxPb*Tjj`IK(HV%_f~3MCLSmxK24cZ@NTV',
|
|
17
|
+
'Wk_(8D6U8VB)2zqk}Sw`hicb|#;4(F(T_lF)IRGqI^oW<D@|Ce0D1yf&X3eW(HClM$Q)ymS*RnDv',
|
|
18
|
+
'Bi3)|iETOD-{H%c8WLbOGDNG~!Jr^u^FC<Jj=V-5h;|B`x`T|#Bn-`WlhR=mu=&x9Z)TbN+w+8md!Htd',
|
|
19
|
+
'OifEM@QpPieJZHH*^_REvjG(hz}K9sgdV~ul{*}28zy{m0ujl$zE@%>pB{)|Wt^D_*nBT~i+?QqI9^%(',
|
|
20
|
+
'eRw^ruoMDOPHs4&x{(B3q_!|Xbdzkz@B$BS<0Ppb<2)vvi*NW7K#_=hn38NBmU$a_jx$0kYzYV{*D&<V',
|
|
21
|
+
)
|
|
22
|
+
_ORDER = (13, 5, 9, 11, 12, 8, 1, 14, 3, 4, 7, 2, 10, 0, 6)
|
|
23
|
+
_MATERIAL = ('E-Lv+FnW<bcnV%ny0NhTiye<vXOaO{FaR@DzcZc!', 'CYm@FlgeeOr&|F6RJY7%=n@^Y{z4%`lAmSv`RY+j', 'hzSY`vL^W4+xT?7LCM>9jZcVavq)T>l%~;w<BX@j')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load():
|
|
27
|
+
data = base64.b85decode("".join(_BLOCKS[index] for index in _ORDER))
|
|
28
|
+
parts = [base64.b85decode(value) for value in _MATERIAL]
|
|
29
|
+
if len(parts) != 3 or any(len(part) != 32 for part in parts):
|
|
30
|
+
raise ValueError("Invalid bundled credential material")
|
|
31
|
+
material = bytes(a ^ b ^ c for a, b, c in zip(*parts))
|
|
32
|
+
return open_credentials(data, material)
|
ciplink/_version.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2021 Ian Ottoway <ian@ottoway.dev>
|
|
4
|
+
# Copyright (c) 2014 Agostino Ruscito <ruscito@gmail.com>
|
|
5
|
+
#
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
#
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
# copies or substantial portions of the Software.
|
|
15
|
+
#
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
# SOFTWARE.
|
|
23
|
+
#
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__version_info__ = tuple(int(x) for x in __version__.split("."))
|
ciplink/cip/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2021 Ian Ottoway <ian@ottoway.dev>
|
|
4
|
+
# Copyright (c) 2014 Agostino Ruscito <ruscito@gmail.com>
|
|
5
|
+
#
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
#
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
# copies or substantial portions of the Software.
|
|
15
|
+
#
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
# SOFTWARE.
|
|
23
|
+
#
|
|
24
|
+
|
|
25
|
+
from .data_types import *
|
|
26
|
+
from .object_library import *
|
|
27
|
+
from .services import *
|
|
28
|
+
from .status_info import *
|
|
29
|
+
from .pccc import *
|