shieldy-sdk 1.0.2__py3-none-win_amd64.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.
shieldy/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """Shieldy Python SDK: license checks, remote variables, files and Protected Strings.
2
+
3
+ One process holds one Shieldy session, because the native runtime does: the calls below are
4
+ module-level functions on that session and are safe to make from any thread.
5
+
6
+ import shieldy
7
+
8
+ shieldy.start(shieldy.Config("<App GUID>", "1.0"), license_key) # raises shieldy.ShieldyError
9
+
10
+ lic = shieldy.license()
11
+ if lic.level >= 20:
12
+ enable_pro_features()
13
+
14
+ url = shieldy.variable_by_id("<Lookup ID>") # str
15
+ max_users = shieldy.variable_by_id_as("<Lookup ID>", int) # int
16
+ with shieldy.file_by_id("<Lookup ID>") as pak: # SecureBytes, wiped after
17
+ load(pak.view())
18
+ token = shieldy.protected_string("<ciphertext>")
19
+
20
+ Every call raises :class:`ShieldyError` when it cannot deliver; ``exc.error`` is an
21
+ :class:`Error` code with the same numbers as the C++ SDK's ``shieldy::Error``.
22
+
23
+ The SDK is a thin client of the native runtime (``native.dll`` / ``native.so``), which does the
24
+ integrity checks and all network traffic. What happens in which module:
25
+
26
+ * ``_native`` - finding and loading the runtime
27
+ * ``_abi`` - the ctypes mirror of the runtime's C ABI (``shieldy_api.h``)
28
+ * ``_crypto`` - key agreement, attestation and decryption of what the runtime returns
29
+ * ``_session`` - the process-wide session and every public call
30
+ * ``obf`` - the ``SHIELDY_OBF`` marker and its release check
31
+ """
32
+
33
+ from ._native import application_directory
34
+ from ._session import (
35
+ checkpoint,
36
+ checkpoint_failed,
37
+ connection_state,
38
+ file,
39
+ file_by_id,
40
+ init,
41
+ is_logged_in,
42
+ license,
43
+ login,
44
+ protected_bytes,
45
+ protected_int,
46
+ protected_string,
47
+ shutdown,
48
+ start,
49
+ supports,
50
+ variable,
51
+ variable_as,
52
+ variable_by_id,
53
+ variable_by_id_as,
54
+ )
55
+ from .errors import Error, ShieldyError
56
+ from .values import Config, ConnectionState, Feature, License, SecureBytes
57
+
58
+ __version__ = "1.0.0"
59
+
60
+ __all__ = [
61
+ "Config",
62
+ "ConnectionState",
63
+ "Error",
64
+ "Feature",
65
+ "License",
66
+ "SecureBytes",
67
+ "ShieldyError",
68
+ "application_directory",
69
+ "checkpoint",
70
+ "checkpoint_failed",
71
+ "connection_state",
72
+ "file",
73
+ "file_by_id",
74
+ "init",
75
+ "is_logged_in",
76
+ "license",
77
+ "login",
78
+ "protected_bytes",
79
+ "protected_int",
80
+ "protected_string",
81
+ "shutdown",
82
+ "start",
83
+ "supports",
84
+ "variable",
85
+ "variable_as",
86
+ "variable_by_id",
87
+ "variable_by_id_as",
88
+ ]
shieldy/_abi.py ADDED
@@ -0,0 +1,120 @@
1
+ """ctypes mirror of ShieldyCore's public ABI, ``cpp-module/shieldy_api.h``.
2
+
3
+ The runtime exports exactly one symbol, ``SC_GetApi``, which fills an ``SC_ApiV1`` table of
4
+ function pointers. Everything else the SDK does goes through that table. The layout below must
5
+ match the header field for field: the table is read by offset, so a reordered or missing field
6
+ calls the wrong function.
7
+
8
+ Kept free of any SDK logic so it can be diffed against the C header on its own. When the header
9
+ gains fields, append them here in the same order (the header only ever appends after ``destroy``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ctypes
15
+ from ctypes import POINTER, Structure, c_char_p, c_int32, c_int64, c_size_t, c_uint8, c_uint32, c_void_p
16
+
17
+ SC_PUBLIC_KEY_SIZE = 32
18
+ SC_API_VERSION_1 = 1
19
+
20
+ # SC_Result. Only SC_OK is compared against here; the other codes are mapped onto
21
+ # shieldy.Error, which uses the same numbers for them (see errors.py).
22
+ SC_OK = 0
23
+ SC_RESULT_MIN_ERROR = 1 # SC_ERROR_UNKNOWN
24
+ SC_RESULT_MAX_ERROR = 17 # SC_ERROR_OFFLINE_UNAVAILABLE
25
+
26
+ # SC_Result is a C enum: int-sized on every platform the runtime ships for.
27
+ SC_Result = ctypes.c_int
28
+
29
+
30
+ class SC_PublicKey(Structure):
31
+ _fields_ = [("bytes", c_uint8 * SC_PUBLIC_KEY_SIZE)]
32
+
33
+
34
+ class SC_Config(Structure):
35
+ # The caller sets struct_size = sizeof(SC_Config) before passing it to create(). A runtime
36
+ # before protocol 1.12.0 compares struct_size with its own, smaller sizeof and accepts it.
37
+ _fields_ = [
38
+ ("struct_size", c_uint32),
39
+ ("appGuid", c_char_p),
40
+ ("appVersion", c_char_p),
41
+ ("appMode", c_int32),
42
+ # Appended in protocol 1.12.0.
43
+ ("leaseDirectory", c_char_p),
44
+ ]
45
+
46
+
47
+ SC_CONNECTION_MODE_ONLINE = 0
48
+ SC_CONNECTION_MODE_OFFLINE = 1
49
+
50
+
51
+ class SC_ConnectionState(Structure):
52
+ # Protocol 1.12.0. The caller sets struct_size = sizeof(SC_ConnectionState).
53
+ _fields_ = [
54
+ ("struct_size", c_uint32),
55
+ ("mode", c_int32),
56
+ ("offline_until", c_int64),
57
+ ]
58
+
59
+
60
+ class SC_Buffer(Structure):
61
+ # As input the caller owns `data`. As output the runtime allocates it and the caller hands it
62
+ # back through free_buffer(). Not NUL-terminated.
63
+ _fields_ = [
64
+ ("data", POINTER(c_uint8)),
65
+ ("size", c_size_t),
66
+ ]
67
+
68
+
69
+ class SC_LoginRequest(Structure):
70
+ _fields_ = [
71
+ ("licenseKey", c_char_p),
72
+ ("sdkPublicKey", SC_PublicKey),
73
+ ]
74
+
75
+
76
+ # SC_Context is opaque: the SDK only ever passes the pointer back.
77
+ SC_ContextPtr = c_void_p
78
+
79
+ # Function pointer types of the table. All of them use the platform's default C calling
80
+ # convention (cdecl on x86), hence CFUNCTYPE rather than WINFUNCTYPE.
81
+ CreateFn = ctypes.CFUNCTYPE(SC_Result, POINTER(SC_Config), POINTER(SC_ContextPtr))
82
+ InitializeFn = ctypes.CFUNCTYPE(SC_Result, SC_ContextPtr)
83
+ LoginFn = ctypes.CFUNCTYPE(SC_Result, SC_ContextPtr, POINTER(SC_LoginRequest), POINTER(SC_PublicKey))
84
+ OpenBlackboxFn = ctypes.CFUNCTYPE(SC_Result, SC_ContextPtr, POINTER(SC_Buffer), POINTER(SC_Buffer))
85
+ # get_license_property, get_variable, deobf_string, download_file and the *_by_id pair all share
86
+ # this shape: (context, NUL-terminated key, out buffer).
87
+ LookupFn = ctypes.CFUNCTYPE(SC_Result, SC_ContextPtr, c_char_p, POINTER(SC_Buffer))
88
+ FreeBufferFn = ctypes.CFUNCTYPE(None, SC_ContextPtr, POINTER(SC_Buffer))
89
+ DestroyFn = ctypes.CFUNCTYPE(None, SC_ContextPtr)
90
+ ConnectionStateFn = ctypes.CFUNCTYPE(SC_Result, SC_ContextPtr, POINTER(SC_ConnectionState))
91
+
92
+
93
+ class SC_ApiV1(Structure):
94
+ # Field order is the ABI. The caller sets struct_size = sizeof(SC_ApiV1) before SC_GetApi.
95
+ _fields_ = [
96
+ ("struct_size", c_uint32),
97
+ ("abi_version", c_uint32),
98
+ ("create", CreateFn),
99
+ ("initialize", InitializeFn),
100
+ ("login", LoginFn),
101
+ ("open_blackbox", OpenBlackboxFn),
102
+ ("get_license_property", LookupFn),
103
+ ("get_variable", LookupFn),
104
+ ("deobf_string", LookupFn),
105
+ ("download_file", LookupFn),
106
+ ("free_buffer", FreeBufferFn),
107
+ ("destroy", DestroyFn),
108
+ # Appended in protocol 1.6.0. A runtime older than that leaves them NULL.
109
+ ("get_variable_by_id", LookupFn),
110
+ ("download_file_by_id", LookupFn),
111
+ # Appended in protocol 1.12.0 (offline start). NULL on older runtimes.
112
+ ("get_connection_state", ConnectionStateFn),
113
+ ]
114
+
115
+
116
+ # The size of SC_ApiV1 as first shipped (up to and including `destroy`). SC_GetApi accepts any
117
+ # struct_size from this up, which is what lets a newer SDK run on an older runtime.
118
+ SC_API_V1_BASE_SIZE = SC_ApiV1.destroy.offset + ctypes.sizeof(DestroyFn)
119
+
120
+ GetApiFn = ctypes.CFUNCTYPE(SC_Result, c_uint32, POINTER(SC_ApiV1))
shieldy/_crypto.py ADDED
@@ -0,0 +1,142 @@
1
+ """The SDK's half of a login: key agreement, attestation and unwrapping the runtime's output.
2
+
3
+ The wire format is fixed by ShieldyCore and mirrored from the C++ SDK (``src/session_crypto.cpp``):
4
+
5
+ * **Key agreement.** Each login generates a fresh X25519 key pair. The SDK sends its public key in
6
+ ``SC_LoginRequest.sdkPublicKey``; the runtime answers with its own. Both sides compute the raw
7
+ X25519 shared secret and hash it with **plain SHA-256** (ShieldyCore ``crypto_ecdh.cpp``,
8
+ ``compute_shared_secret``). That digest is the 32-byte session key.
9
+ * **Attestation.** Before trusting anything else, the SDK sends 16 random bytes to
10
+ ``open_blackbox``; the runtime must answer ``HMAC-SHA256(session_key, nonce)``. A runtime that
11
+ cannot is not the one that did the key agreement.
12
+ * **Output wrapping.** Every value the runtime hands back (license fields, variables, files,
13
+ Protected Strings) is ``nonce(12) || tag(16) || ciphertext`` under ChaCha20-Poly1305 (IETF)
14
+ with the session key and no associated data. Note the tag sits *before* the ciphertext; most
15
+ libraries, ``cryptography`` included, expect it after, so :meth:`Session.decrypt` reorders.
16
+
17
+ Memory hygiene is best effort, as it has to be in Python: the session key lives in a
18
+ ``bytearray`` that is zeroed when the session ends, and decrypted files are written straight into
19
+ a buffer :class:`~shieldy.SecureBytes` can wipe. Copies the interpreter or OpenSSL make
20
+ internally are out of the SDK's reach.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import hmac
27
+ import os
28
+ from typing import Optional
29
+
30
+ from cryptography.exceptions import InvalidTag
31
+ from cryptography.hazmat.primitives import serialization
32
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
33
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
34
+
35
+ from .errors import Error, ShieldyError
36
+ from .values import SecureBytes
37
+
38
+ KEY_SIZE = 32
39
+ CHALLENGE_NONCE_SIZE = 16 # the size open_blackbox accepts
40
+ NONCE_SIZE = 12
41
+ TAG_SIZE = 16
42
+
43
+
44
+ def random_bytes(size: int) -> bytes:
45
+ return os.urandom(size)
46
+
47
+
48
+ def wipe(buffer: bytearray) -> None:
49
+ """Overwrite a mutable buffer with zeros in place."""
50
+ buffer[:] = bytes(len(buffer))
51
+
52
+
53
+ class Session:
54
+ """One login's key material. A new :meth:`begin` drops the previous session entirely."""
55
+
56
+ def __init__(self) -> None:
57
+ self._private_key: Optional[X25519PrivateKey] = None
58
+ self._public_key = b""
59
+ self._session_key = bytearray()
60
+ self._aead: Optional[ChaCha20Poly1305] = None
61
+
62
+ def begin(self) -> bytes:
63
+ """Generate a fresh key pair and return the public half to send to the runtime."""
64
+ self.clear()
65
+ self._private_key = X25519PrivateKey.generate()
66
+ self._public_key = self._private_key.public_key().public_bytes(
67
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
68
+ )
69
+ return self._public_key
70
+
71
+ @property
72
+ def public_key(self) -> bytes:
73
+ return self._public_key
74
+
75
+ @property
76
+ def has_key(self) -> bool:
77
+ return self._aead is not None
78
+
79
+ def derive(self, peer_public_key: bytes) -> bool:
80
+ """Turn the runtime's public key into the session key. False when the exchange is invalid.
81
+
82
+ The private key is dropped either way: it is single use.
83
+ """
84
+ private_key, self._private_key = self._private_key, None
85
+ if private_key is None or len(peer_public_key) != KEY_SIZE:
86
+ return False
87
+ try:
88
+ # Raises ValueError for a low-order peer key (all-zero shared secret), which is the
89
+ # same input libsodium's crypto_scalarmult refuses in the C++ SDK.
90
+ shared = bytearray(private_key.exchange(X25519PublicKey.from_public_bytes(peer_public_key)))
91
+ except ValueError:
92
+ return False
93
+ self._session_key = bytearray(hashlib.sha256(shared).digest())
94
+ wipe(shared)
95
+ self._aead = ChaCha20Poly1305(bytes(self._session_key))
96
+ return True
97
+
98
+ def verify_challenge(self, nonce: bytes, response: bytes) -> bool:
99
+ """Check ``response == HMAC-SHA256(session_key, nonce)`` in constant time."""
100
+ if not self.has_key or len(response) != hashlib.sha256().digest_size:
101
+ return False
102
+ expected = hmac.new(bytes(self._session_key), nonce, hashlib.sha256).digest()
103
+ return hmac.compare_digest(expected, response)
104
+
105
+ def decrypt(self, wrapped: bytes) -> SecureBytes:
106
+ """Unwrap one value from the runtime into a wipeable buffer.
107
+
108
+ Raises ``ShieldyError(NOT_LOGGED_IN)`` without a session key and
109
+ ``ShieldyError(DECRYPT_FAILED)`` when the value is malformed or fails authentication.
110
+ """
111
+ if self._aead is None:
112
+ raise ShieldyError(Error.NOT_LOGGED_IN)
113
+ if len(wrapped) < NONCE_SIZE + TAG_SIZE:
114
+ raise ShieldyError(Error.DECRYPT_FAILED)
115
+
116
+ nonce = wrapped[:NONCE_SIZE]
117
+ tag = wrapped[NONCE_SIZE:NONCE_SIZE + TAG_SIZE]
118
+ ciphertext = wrapped[NONCE_SIZE + TAG_SIZE:]
119
+ plain = bytearray(len(ciphertext))
120
+ try:
121
+ self._decrypt_into(nonce, ciphertext + tag, plain)
122
+ except InvalidTag:
123
+ wipe(plain)
124
+ raise ShieldyError(Error.DECRYPT_FAILED) from None
125
+ return SecureBytes._adopt(plain)
126
+
127
+ def _decrypt_into(self, nonce: bytes, ciphertext_and_tag: bytes, out: bytearray) -> None:
128
+ assert self._aead is not None
129
+ decrypt_into = getattr(self._aead, "decrypt_into", None)
130
+ if decrypt_into is not None:
131
+ # cryptography >= 45: plaintext goes straight into our buffer, no immutable copy.
132
+ decrypt_into(nonce, ciphertext_and_tag, None, out)
133
+ else:
134
+ out[:] = self._aead.decrypt(nonce, ciphertext_and_tag, None)
135
+
136
+ def clear(self) -> None:
137
+ """Forget everything; the next value needs a new login."""
138
+ self._private_key = None
139
+ self._public_key = b""
140
+ wipe(self._session_key)
141
+ self._session_key = bytearray()
142
+ self._aead = None
shieldy/_native.py ADDED
@@ -0,0 +1,128 @@
1
+ """Finding and loading the native runtime.
2
+
3
+ The runtime is a shared library (``native.dll`` on Windows, ``native.so`` on Linux) that your
4
+ application ships. Which file gets loaded is a security decision, so the rules are strict:
5
+
6
+ 1. ``Config.native_library`` if set, else the ``SHIELDY_NATIVE_LIBRARY`` environment variable,
7
+ else the default file name.
8
+ 2. A relative path is resolved against :func:`application_directory`, and then against the
9
+ package's own directory (:func:`bundled_directories`, where a wheel that bundles the runtime
10
+ puts it), **never** the current working directory or ``PATH``, so a library planted where the
11
+ user happens to start your program from is not picked up.
12
+ 3. On Windows the library's own dependencies are searched only in its directory and System32
13
+ (``LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32``), as in the C++ SDK.
14
+
15
+ A loaded runtime is never unloaded: it keeps protection threads running for the life of the
16
+ process.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ctypes
22
+ import os
23
+ import sys
24
+ from typing import Any, List, Optional
25
+
26
+ from . import _abi
27
+ from .errors import Error, ShieldyError
28
+
29
+ ENVIRONMENT_VARIABLE = "SHIELDY_NATIVE_LIBRARY"
30
+
31
+ def _default_file_name(platform: str) -> str:
32
+ """The runtime's file name on ``platform`` (a ``sys.platform`` value)."""
33
+ if platform == "win32":
34
+ return "native.dll"
35
+ if platform == "darwin":
36
+ return "native.dylib"
37
+ return "native.so"
38
+
39
+
40
+ DEFAULT_FILE_NAME = _default_file_name(sys.platform)
41
+
42
+ _LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR = 0x00000100
43
+ _LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800
44
+
45
+ # Loaded libraries, kept referenced so ctypes never lets go of them.
46
+ _loaded: List[ctypes.CDLL] = []
47
+
48
+
49
+ def application_directories() -> List[str]:
50
+ """The directories a relative runtime path is looked up in, in order.
51
+
52
+ * A frozen application (PyInstaller, cx_Freeze, py2exe): the directory of the executable,
53
+ then PyInstaller's bundle directory (``sys._MEIPASS``) when it differs, which is where a
54
+ one-file build unpacks ``--add-binary`` files.
55
+ * A script: the directory of the ``__main__`` script, i.e. the file you ran with ``python``.
56
+ For ``python -m package`` that is the package's directory.
57
+ * No main script (interactive interpreter, ``python -c``, an embedding host): none. Pass an
58
+ absolute path then.
59
+ """
60
+ if getattr(sys, "frozen", False):
61
+ directories = [os.path.dirname(os.path.abspath(sys.executable))]
62
+ bundle = getattr(sys, "_MEIPASS", None)
63
+ if bundle and os.path.abspath(bundle) not in directories:
64
+ directories.append(os.path.abspath(bundle))
65
+ return directories
66
+
67
+ main = sys.modules.get("__main__")
68
+ main_file = getattr(main, "__file__", None)
69
+ if main_file:
70
+ return [os.path.dirname(os.path.abspath(main_file))]
71
+ return []
72
+
73
+
74
+ def application_directory() -> Optional[str]:
75
+ """The first of :func:`application_directories`, or ``None`` when there is none."""
76
+ directories = application_directories()
77
+ return directories[0] if directories else None
78
+
79
+
80
+ def bundled_directories() -> List[str]:
81
+ """Install locations a relative runtime path falls back to, after the application directories.
82
+
83
+ The package's own directory: where a wheel that bundles the runtime (the per-platform releases
84
+ on PyPI) places ``native.dll`` / ``native.so`` / ``native.dylib``. This is the install location, not the current
85
+ working directory or ``PATH``, so it is as trustworthy as the application directory. A source
86
+ build with no bundled runtime simply has nothing here, and the application's own copy wins.
87
+ """
88
+ return [os.path.dirname(os.path.abspath(__file__))]
89
+
90
+
91
+ def resolve(configured_path: str) -> Optional[str]:
92
+ """The absolute path that :func:`load_get_api` would load, or ``None`` when it cannot tell."""
93
+ path = configured_path or os.environ.get(ENVIRONMENT_VARIABLE, "") or DEFAULT_FILE_NAME
94
+ if os.path.isabs(path):
95
+ return os.path.normpath(path)
96
+ candidates = [
97
+ os.path.normpath(os.path.join(directory, path))
98
+ for directory in application_directories() + bundled_directories()
99
+ ]
100
+ for candidate in candidates:
101
+ if os.path.isfile(candidate):
102
+ return candidate
103
+ # Nothing exists yet: report the first candidate so the error names a useful place.
104
+ return candidates[0] if candidates else None
105
+
106
+
107
+ def _open(path: str) -> ctypes.CDLL:
108
+ if sys.platform == "win32":
109
+ return ctypes.CDLL(path, winmode=_LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | _LOAD_LIBRARY_SEARCH_SYSTEM32)
110
+ return ctypes.CDLL(path, mode=os.RTLD_NOW | os.RTLD_LOCAL)
111
+
112
+
113
+ def load_get_api(configured_path: str) -> Any:
114
+ """Load the runtime and return its ``SC_GetApi`` as a callable :data:`_abi.GetApiFn`."""
115
+ path = resolve(configured_path)
116
+ if path is None or not os.path.isfile(path):
117
+ raise ShieldyError(Error.NATIVE_LIBRARY_NOT_FOUND)
118
+ try:
119
+ library = _open(path)
120
+ except OSError:
121
+ # Also what a library for the wrong architecture (x86 DLL in 64-bit Python) ends up as.
122
+ raise ShieldyError(Error.NATIVE_LIBRARY_NOT_FOUND) from None
123
+ try:
124
+ get_api = _abi.GetApiFn(("SC_GetApi", library))
125
+ except AttributeError:
126
+ raise ShieldyError(Error.NATIVE_LIBRARY_INVALID) from None
127
+ _loaded.append(library)
128
+ return get_api