pxa-security 1.0.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.
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Daniel Lee with CLAUDE
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: pxa-security
3
+ Version: 1.0.0
4
+ Summary: Purpose-specific credential and user-info encryption utilities.
5
+ Author-email: Daniel Lee <rootuser.kr@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Keywords: encryption,aes,aes-gcm,password,kdf,scrypt
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Security :: Cryptography
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cryptography>=42
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest; extra == "test"
16
+ Dynamic: license-file
17
+
18
+ # pxa-security
19
+
20
+ Purpose-specific credential and user-info encryption utilities.
21
+
22
+ ```bash
23
+ pip install pxa-security
24
+ ```
25
+
26
+ The distribution name is `pxa-security`; the import name is `pxa_security`.
27
+
28
+ ## Credential API
29
+
30
+ Use a secret master key that is supplied separately from the configuration
31
+ file, normally through an environment variable or a secret manager. Tokens
32
+ use scrypt with a per-token random salt and AES-GCM authenticated
33
+ encryption.
34
+
35
+ ```python
36
+ import os
37
+
38
+ from pxa_security import encrypt_credential, decrypt_credential
39
+
40
+ master_key = os.environ["PXA_CREDENTIAL_MASTER_KEY"]
41
+
42
+ # The token is safe to store in a config file; the master key is not.
43
+ token = encrypt_credential(master_key, "s3cret!")
44
+
45
+ # Decrypt with the separately supplied master key.
46
+ password = decrypt_credential(master_key, token)
47
+ assert password == "s3cret!"
48
+ ```
49
+
50
+ A wrong master key, damaged token, or modified token raises `ValueError`:
51
+
52
+ ```python
53
+ decrypt_credential("wrong-master-key", token) # ValueError
54
+ ```
55
+
56
+ Use at least 32 random bytes for the master key and never write it beside the
57
+ encrypted token. A username, application name, or other public identifier is
58
+ not a master key. One suitable value can be generated with
59
+ `secrets.token_urlsafe(32)` and then provisioned through your deployment's
60
+ secret store.
61
+
62
+ A master key shorter than 32 bytes is accepted — every token carries a
63
+ random scrypt salt, so short keys still encrypt and decrypt correctly — but
64
+ `encrypt_credential` emits a `UserWarning` to keep the recommendation
65
+ visible. Decryption never warns.
66
+
67
+ ## User-info API
68
+
69
+ AES-128-CBC in a wire format compatible with the legacy Node.js
70
+ implementation: ciphertext is a hex string, the IV equals the key, and the
71
+ plaintext is a JSON object whose string values are wrapped in MIME
72
+ B-encoding (`=?UTF-8?B?<base64>?=`).
73
+
74
+ ```python
75
+ from pxa_security import encrypt_userinfo, decrypt_userinfo
76
+
77
+ key = "0123456789abcdef" # must be exactly 16 bytes (AES-128)
78
+
79
+ # Pass a dict: values are MIME-encoded and JSON-serialized automatically
80
+ data = encrypt_userinfo({"name": "홍길동", "dept": "IT"}, key)
81
+
82
+ userinfo = decrypt_userinfo(data, key)
83
+ assert userinfo == {"name": "홍길동", "dept": "IT"}
84
+ ```
85
+
86
+ `encrypt_userinfo` also accepts a ready-made plaintext string (legacy
87
+ usage), and `decrypt_userinfo` decrypts data produced by the original
88
+ Node.js code as-is.
89
+
90
+ ## API summary
91
+
92
+ | Function | Description |
93
+ | --- | --- |
94
+ | `encrypt_credential(master_key: str \| bytes, credential: str) -> str` | Encrypts a credential with a separately managed secret and returns an ASCII token. |
95
+ | `decrypt_credential(master_key: str \| bytes, encrypted_credential: str \| bytes) -> str` | Authenticates and decrypts a token produced by `encrypt_credential`. |
96
+ | `encrypt_userinfo(userinfo: str \| dict, key: str) -> str` | Encrypts user info with AES-128-CBC (legacy format); returns a hex string. |
97
+ | `decrypt_userinfo(encrypted_userinfo: str, key: str) -> dict` | Decrypts the hex string and returns the user-info dict with MIME-encoded values decoded. Raises `ValueError` on failure. |
98
+
99
+ ## Security notes
100
+
101
+ - **Credential API**: AES-GCM ensures ciphertext modification and
102
+ wrong keys are detected. scrypt makes offline guessing more expensive, but
103
+ it cannot compensate for a weak or exposed master key. Keep the master key
104
+ out of source control and configuration files.
105
+ - **User-info API**: the legacy format reuses the key as the IV, which makes
106
+ encryption deterministic (identical plaintexts produce identical
107
+ ciphertexts) and is kept **only for interoperability with existing data**.
108
+ Prefer the credential API for new data.
109
+
110
+ ## License
111
+
112
+ Apache License 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,95 @@
1
+ # pxa-security
2
+
3
+ Purpose-specific credential and user-info encryption utilities.
4
+
5
+ ```bash
6
+ pip install pxa-security
7
+ ```
8
+
9
+ The distribution name is `pxa-security`; the import name is `pxa_security`.
10
+
11
+ ## Credential API
12
+
13
+ Use a secret master key that is supplied separately from the configuration
14
+ file, normally through an environment variable or a secret manager. Tokens
15
+ use scrypt with a per-token random salt and AES-GCM authenticated
16
+ encryption.
17
+
18
+ ```python
19
+ import os
20
+
21
+ from pxa_security import encrypt_credential, decrypt_credential
22
+
23
+ master_key = os.environ["PXA_CREDENTIAL_MASTER_KEY"]
24
+
25
+ # The token is safe to store in a config file; the master key is not.
26
+ token = encrypt_credential(master_key, "s3cret!")
27
+
28
+ # Decrypt with the separately supplied master key.
29
+ password = decrypt_credential(master_key, token)
30
+ assert password == "s3cret!"
31
+ ```
32
+
33
+ A wrong master key, damaged token, or modified token raises `ValueError`:
34
+
35
+ ```python
36
+ decrypt_credential("wrong-master-key", token) # ValueError
37
+ ```
38
+
39
+ Use at least 32 random bytes for the master key and never write it beside the
40
+ encrypted token. A username, application name, or other public identifier is
41
+ not a master key. One suitable value can be generated with
42
+ `secrets.token_urlsafe(32)` and then provisioned through your deployment's
43
+ secret store.
44
+
45
+ A master key shorter than 32 bytes is accepted — every token carries a
46
+ random scrypt salt, so short keys still encrypt and decrypt correctly — but
47
+ `encrypt_credential` emits a `UserWarning` to keep the recommendation
48
+ visible. Decryption never warns.
49
+
50
+ ## User-info API
51
+
52
+ AES-128-CBC in a wire format compatible with the legacy Node.js
53
+ implementation: ciphertext is a hex string, the IV equals the key, and the
54
+ plaintext is a JSON object whose string values are wrapped in MIME
55
+ B-encoding (`=?UTF-8?B?<base64>?=`).
56
+
57
+ ```python
58
+ from pxa_security import encrypt_userinfo, decrypt_userinfo
59
+
60
+ key = "0123456789abcdef" # must be exactly 16 bytes (AES-128)
61
+
62
+ # Pass a dict: values are MIME-encoded and JSON-serialized automatically
63
+ data = encrypt_userinfo({"name": "홍길동", "dept": "IT"}, key)
64
+
65
+ userinfo = decrypt_userinfo(data, key)
66
+ assert userinfo == {"name": "홍길동", "dept": "IT"}
67
+ ```
68
+
69
+ `encrypt_userinfo` also accepts a ready-made plaintext string (legacy
70
+ usage), and `decrypt_userinfo` decrypts data produced by the original
71
+ Node.js code as-is.
72
+
73
+ ## API summary
74
+
75
+ | Function | Description |
76
+ | --- | --- |
77
+ | `encrypt_credential(master_key: str \| bytes, credential: str) -> str` | Encrypts a credential with a separately managed secret and returns an ASCII token. |
78
+ | `decrypt_credential(master_key: str \| bytes, encrypted_credential: str \| bytes) -> str` | Authenticates and decrypts a token produced by `encrypt_credential`. |
79
+ | `encrypt_userinfo(userinfo: str \| dict, key: str) -> str` | Encrypts user info with AES-128-CBC (legacy format); returns a hex string. |
80
+ | `decrypt_userinfo(encrypted_userinfo: str, key: str) -> dict` | Decrypts the hex string and returns the user-info dict with MIME-encoded values decoded. Raises `ValueError` on failure. |
81
+
82
+ ## Security notes
83
+
84
+ - **Credential API**: AES-GCM ensures ciphertext modification and
85
+ wrong keys are detected. scrypt makes offline guessing more expensive, but
86
+ it cannot compensate for a weak or exposed master key. Keep the master key
87
+ out of source control and configuration files.
88
+ - **User-info API**: the legacy format reuses the key as the IV, which makes
89
+ encryption deterministic (identical plaintexts produce identical
90
+ ciphertexts) and is kept **only for interoperability with existing data**.
91
+ Prefer the credential API for new data.
92
+
93
+ ## License
94
+
95
+ Apache License 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pxa-security"
7
+ version = "1.0.0"
8
+ description = "Purpose-specific credential and user-info encryption utilities."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Daniel Lee", email = "rootuser.kr@gmail.com" }]
13
+ keywords = ["encryption", "aes", "aes-gcm", "password", "kdf", "scrypt"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Security :: Cryptography",
17
+ ]
18
+ dependencies = ["cryptography>=42"]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest"]
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.pytest.ini_options]
27
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,43 @@
1
+ # Copyright 2026 Daniel Lee with CLAUDE
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """pxa-security — purpose-specific encryption utilities.
16
+
17
+ Credential API (secret master key kept outside the configuration file):
18
+
19
+ >>> from pxa_security import encrypt_credential, decrypt_credential
20
+ >>> master_key = "application-master-secret-with-32-bytes"
21
+ >>> token = encrypt_credential(master_key, "s3cret!")
22
+ >>> decrypt_credential(master_key, token)
23
+ 's3cret!'
24
+
25
+ User-info API (AES-128-CBC hex format, compatible with the legacy
26
+ Node.js implementation):
27
+
28
+ >>> from pxa_security import encrypt_userinfo, decrypt_userinfo
29
+ >>> data = encrypt_userinfo({"name": "alice"}, "0123456789abcdef")
30
+ >>> decrypt_userinfo(data, "0123456789abcdef")
31
+ {'name': 'alice'}
32
+ """
33
+
34
+ from pxa_security._credential import decrypt_credential, encrypt_credential
35
+ from pxa_security._userinfo import decrypt_userinfo, encrypt_userinfo
36
+
37
+ __all__ = [
38
+ "encrypt_credential",
39
+ "decrypt_credential",
40
+ "encrypt_userinfo",
41
+ "decrypt_userinfo",
42
+ ]
43
+ __version__ = "3.0.0"
@@ -0,0 +1,131 @@
1
+ # Copyright 2026 Daniel Lee with CLAUDE
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Authenticated encryption for credentials stored in configuration files.
16
+
17
+ The first argument is a *secret* master key supplied separately from the
18
+ configuration file (for example through an environment variable or secret
19
+ manager). Tokens use scrypt with a per-token random salt and AES-GCM.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import binascii
26
+ import os
27
+
28
+ from cryptography.exceptions import InvalidTag
29
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
30
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
31
+
32
+ __all__ = ["encrypt_credential", "decrypt_credential"]
33
+
34
+ _AAD = b"pxa-security:credential"
35
+ _SALT_SIZE = 16
36
+ _NONCE_SIZE = 12
37
+ _KEY_SIZE = 32
38
+ _SCRYPT_N = 2**14
39
+ _SCRYPT_R = 8
40
+ _SCRYPT_P = 1
41
+
42
+
43
+ def _secret_bytes(master_key: str | bytes) -> bytes:
44
+ if isinstance(master_key, str):
45
+ secret = master_key.encode("utf-8")
46
+ elif isinstance(master_key, bytes):
47
+ secret = master_key
48
+ else:
49
+ raise TypeError("master_key must be str or bytes")
50
+ if not secret:
51
+ raise ValueError("master_key must not be empty")
52
+ return secret
53
+
54
+
55
+ def _derive_key(master_key: bytes, salt: bytes) -> bytes:
56
+ return Scrypt(
57
+ salt=salt,
58
+ length=_KEY_SIZE,
59
+ n=_SCRYPT_N,
60
+ r=_SCRYPT_R,
61
+ p=_SCRYPT_P,
62
+ ).derive(master_key)
63
+
64
+
65
+ def _encode_payload(payload: bytes) -> str:
66
+ return base64.urlsafe_b64encode(payload).decode("ascii")
67
+
68
+
69
+ def _decode_payload(payload: str) -> bytes:
70
+ try:
71
+ return base64.b64decode(
72
+ payload.encode("ascii"), altchars=b"-_", validate=True
73
+ )
74
+ except (UnicodeEncodeError, binascii.Error, ValueError):
75
+ raise ValueError("decryption failed: malformed credential token") from None
76
+
77
+
78
+ def encrypt_credential(master_key: str | bytes, credential: str) -> str:
79
+ """Encrypt ``credential`` for storage in a configuration file.
80
+
81
+ ``master_key`` must be a secret kept separately from the returned token.
82
+ A high-entropy value of at least 32 random bytes is recommended. The
83
+ returned ASCII token includes a random KDF salt and nonce, but never the
84
+ master key.
85
+ """
86
+ if not isinstance(credential, str):
87
+ raise TypeError("credential must be str")
88
+
89
+ secret = _secret_bytes(master_key)
90
+ salt = os.urandom(_SALT_SIZE)
91
+ nonce = os.urandom(_NONCE_SIZE)
92
+ key = _derive_key(secret, salt)
93
+ ciphertext = AESGCM(key).encrypt(nonce, credential.encode("utf-8"), _AAD)
94
+ return _encode_payload(salt + nonce + ciphertext)
95
+
96
+
97
+ def decrypt_credential(
98
+ master_key: str | bytes, encrypted_credential: str | bytes
99
+ ) -> str:
100
+ """Decrypt a token produced by :func:`encrypt_credential`.
101
+
102
+ Raises:
103
+ ValueError: if the key is wrong or the token is malformed/corrupted.
104
+ """
105
+ secret = _secret_bytes(master_key)
106
+ if isinstance(encrypted_credential, bytes):
107
+ try:
108
+ token = encrypted_credential.decode("ascii")
109
+ except UnicodeDecodeError:
110
+ raise ValueError("decryption failed: malformed credential token") from None
111
+ elif isinstance(encrypted_credential, str):
112
+ token = encrypted_credential
113
+ else:
114
+ raise TypeError("encrypted_credential must be str or bytes")
115
+
116
+ payload = _decode_payload(token)
117
+ minimum_size = _SALT_SIZE + _NONCE_SIZE + 16 # GCM authentication tag
118
+ if len(payload) < minimum_size:
119
+ raise ValueError("decryption failed: malformed credential token")
120
+ salt = payload[:_SALT_SIZE]
121
+ nonce = payload[_SALT_SIZE : _SALT_SIZE + _NONCE_SIZE]
122
+ ciphertext = payload[_SALT_SIZE + _NONCE_SIZE :]
123
+ try:
124
+ plaintext = AESGCM(_derive_key(secret, salt)).decrypt(
125
+ nonce, ciphertext, _AAD
126
+ )
127
+ return plaintext.decode("utf-8")
128
+ except (InvalidTag, UnicodeDecodeError):
129
+ raise ValueError(
130
+ "decryption failed: wrong master_key or corrupted credential token"
131
+ ) from None
@@ -0,0 +1,106 @@
1
+ # Copyright 2026 Daniel Lee with CLAUDE
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """User-info encryption compatible with the legacy Node.js implementation.
16
+
17
+ Wire format kept for interoperability with existing data:
18
+
19
+ - AES-128-CBC with PKCS7 padding, ciphertext as a lowercase hex string.
20
+ - The IV equals the key (legacy behaviour — encryption is deterministic,
21
+ so identical plaintexts produce identical ciphertexts).
22
+ - The plaintext is a JSON object whose string values are wrapped in MIME
23
+ B-encoding (``=?UTF-8?B?<base64>?=``); :func:`decrypt_userinfo` unwraps
24
+ them automatically.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import json
31
+
32
+ from cryptography.hazmat.primitives import padding
33
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
34
+
35
+ __all__ = ["encrypt_userinfo", "decrypt_userinfo"]
36
+
37
+ _BLOCK_SIZE_BYTES = 16 # AES block size in bytes
38
+ _MIME_PREFIX = "=?UTF-8?B?"
39
+ _MIME_SUFFIX = "?="
40
+
41
+
42
+ def _encode(plain: str) -> str:
43
+ """Wrap a value as ``=?UTF-8?B?<base64>?=`` (node encode())."""
44
+ encoded = base64.b64encode(plain.encode("utf-8")).decode("ascii")
45
+ return _MIME_PREFIX + encoded + _MIME_SUFFIX
46
+
47
+
48
+ def _decode(encoded):
49
+ """Unwrap a MIME-encoded value; return non-encoded values as-is (node decode())."""
50
+ if isinstance(encoded, str) and encoded.startswith(_MIME_PREFIX):
51
+ temp = encoded[len(_MIME_PREFIX):encoded.index(_MIME_SUFFIX)]
52
+ return base64.b64decode(temp).decode("utf-8")
53
+ return encoded
54
+
55
+
56
+ def _key_bytes(key: str) -> bytes:
57
+ key_bytes = key.encode("utf-8")
58
+ if len(key_bytes) != _BLOCK_SIZE_BYTES:
59
+ raise ValueError(
60
+ "key must be exactly 16 bytes when UTF-8 encoded "
61
+ f"(got {len(key_bytes)} bytes): the legacy format uses AES-128 "
62
+ "with the key doubling as the IV"
63
+ )
64
+ return key_bytes
65
+
66
+
67
+ def encrypt_userinfo(userinfo: str | dict, key: str) -> str:
68
+ """Encrypt user info with AES-128-CBC and return a hex string.
69
+
70
+ ``userinfo`` may be either a ready-made plaintext string (legacy usage)
71
+ or a dict — dict values are MIME-B-encoded and JSON-serialized first, so
72
+ that :func:`decrypt_userinfo` returns the original dict.
73
+ """
74
+ if isinstance(userinfo, dict):
75
+ userinfo = json.dumps(
76
+ {k: _encode(v) if isinstance(v, str) else v for k, v in userinfo.items()},
77
+ ensure_ascii=True,
78
+ )
79
+ key_bytes = _key_bytes(key)
80
+ padder = padding.PKCS7(_BLOCK_SIZE_BYTES * 8).padder()
81
+ padded = padder.update(userinfo.encode("utf-8")) + padder.finalize()
82
+ encryptor = Cipher(algorithms.AES(key_bytes), modes.CBC(key_bytes)).encryptor()
83
+ return (encryptor.update(padded) + encryptor.finalize()).hex()
84
+
85
+
86
+ def decrypt_userinfo(encrypted_userinfo: str, key: str) -> dict:
87
+ """Decrypt a hex string produced by :func:`encrypt_userinfo` (or the
88
+ legacy Node.js implementation) and return the user-info dict with all
89
+ MIME-encoded values decoded.
90
+
91
+ Raises:
92
+ ValueError: if the key is wrong or the data is corrupted.
93
+ """
94
+ key_bytes = _key_bytes(key)
95
+ try:
96
+ ciphertext = bytes.fromhex(encrypted_userinfo)
97
+ decryptor = Cipher(algorithms.AES(key_bytes), modes.CBC(key_bytes)).decryptor()
98
+ padded = decryptor.update(ciphertext) + decryptor.finalize()
99
+ unpadder = padding.PKCS7(_BLOCK_SIZE_BYTES * 8).unpadder()
100
+ plaintext = unpadder.update(padded) + unpadder.finalize()
101
+ decrypted_json = json.loads(plaintext.decode("utf-8"))
102
+ except (ValueError, UnicodeDecodeError):
103
+ raise ValueError(
104
+ "decryption failed: wrong key or corrupted encrypted_userinfo"
105
+ ) from None
106
+ return {k: _decode(v) for k, v in decrypted_json.items()}
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: pxa-security
3
+ Version: 1.0.0
4
+ Summary: Purpose-specific credential and user-info encryption utilities.
5
+ Author-email: Daniel Lee <rootuser.kr@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Keywords: encryption,aes,aes-gcm,password,kdf,scrypt
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Security :: Cryptography
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cryptography>=42
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest; extra == "test"
16
+ Dynamic: license-file
17
+
18
+ # pxa-security
19
+
20
+ Purpose-specific credential and user-info encryption utilities.
21
+
22
+ ```bash
23
+ pip install pxa-security
24
+ ```
25
+
26
+ The distribution name is `pxa-security`; the import name is `pxa_security`.
27
+
28
+ ## Credential API
29
+
30
+ Use a secret master key that is supplied separately from the configuration
31
+ file, normally through an environment variable or a secret manager. Tokens
32
+ use scrypt with a per-token random salt and AES-GCM authenticated
33
+ encryption.
34
+
35
+ ```python
36
+ import os
37
+
38
+ from pxa_security import encrypt_credential, decrypt_credential
39
+
40
+ master_key = os.environ["PXA_CREDENTIAL_MASTER_KEY"]
41
+
42
+ # The token is safe to store in a config file; the master key is not.
43
+ token = encrypt_credential(master_key, "s3cret!")
44
+
45
+ # Decrypt with the separately supplied master key.
46
+ password = decrypt_credential(master_key, token)
47
+ assert password == "s3cret!"
48
+ ```
49
+
50
+ A wrong master key, damaged token, or modified token raises `ValueError`:
51
+
52
+ ```python
53
+ decrypt_credential("wrong-master-key", token) # ValueError
54
+ ```
55
+
56
+ Use at least 32 random bytes for the master key and never write it beside the
57
+ encrypted token. A username, application name, or other public identifier is
58
+ not a master key. One suitable value can be generated with
59
+ `secrets.token_urlsafe(32)` and then provisioned through your deployment's
60
+ secret store.
61
+
62
+ A master key shorter than 32 bytes is accepted — every token carries a
63
+ random scrypt salt, so short keys still encrypt and decrypt correctly — but
64
+ `encrypt_credential` emits a `UserWarning` to keep the recommendation
65
+ visible. Decryption never warns.
66
+
67
+ ## User-info API
68
+
69
+ AES-128-CBC in a wire format compatible with the legacy Node.js
70
+ implementation: ciphertext is a hex string, the IV equals the key, and the
71
+ plaintext is a JSON object whose string values are wrapped in MIME
72
+ B-encoding (`=?UTF-8?B?<base64>?=`).
73
+
74
+ ```python
75
+ from pxa_security import encrypt_userinfo, decrypt_userinfo
76
+
77
+ key = "0123456789abcdef" # must be exactly 16 bytes (AES-128)
78
+
79
+ # Pass a dict: values are MIME-encoded and JSON-serialized automatically
80
+ data = encrypt_userinfo({"name": "홍길동", "dept": "IT"}, key)
81
+
82
+ userinfo = decrypt_userinfo(data, key)
83
+ assert userinfo == {"name": "홍길동", "dept": "IT"}
84
+ ```
85
+
86
+ `encrypt_userinfo` also accepts a ready-made plaintext string (legacy
87
+ usage), and `decrypt_userinfo` decrypts data produced by the original
88
+ Node.js code as-is.
89
+
90
+ ## API summary
91
+
92
+ | Function | Description |
93
+ | --- | --- |
94
+ | `encrypt_credential(master_key: str \| bytes, credential: str) -> str` | Encrypts a credential with a separately managed secret and returns an ASCII token. |
95
+ | `decrypt_credential(master_key: str \| bytes, encrypted_credential: str \| bytes) -> str` | Authenticates and decrypts a token produced by `encrypt_credential`. |
96
+ | `encrypt_userinfo(userinfo: str \| dict, key: str) -> str` | Encrypts user info with AES-128-CBC (legacy format); returns a hex string. |
97
+ | `decrypt_userinfo(encrypted_userinfo: str, key: str) -> dict` | Decrypts the hex string and returns the user-info dict with MIME-encoded values decoded. Raises `ValueError` on failure. |
98
+
99
+ ## Security notes
100
+
101
+ - **Credential API**: AES-GCM ensures ciphertext modification and
102
+ wrong keys are detected. scrypt makes offline guessing more expensive, but
103
+ it cannot compensate for a weak or exposed master key. Keep the master key
104
+ out of source control and configuration files.
105
+ - **User-info API**: the legacy format reuses the key as the IV, which makes
106
+ encryption deterministic (identical plaintexts produce identical
107
+ ciphertexts) and is kept **only for interoperability with existing data**.
108
+ Prefer the credential API for new data.
109
+
110
+ ## License
111
+
112
+ Apache License 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pxa_security/__init__.py
5
+ src/pxa_security/_credential.py
6
+ src/pxa_security/_userinfo.py
7
+ src/pxa_security.egg-info/PKG-INFO
8
+ src/pxa_security.egg-info/SOURCES.txt
9
+ src/pxa_security.egg-info/dependency_links.txt
10
+ src/pxa_security.egg-info/requires.txt
11
+ src/pxa_security.egg-info/top_level.txt
12
+ tests/test_pxa_security.py
@@ -0,0 +1,4 @@
1
+ cryptography>=42
2
+
3
+ [test]
4
+ pytest
@@ -0,0 +1 @@
1
+ pxa_security
@@ -0,0 +1,148 @@
1
+ # Copyright 2026 Daniel Lee with CLAUDE
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+
17
+ import pytest
18
+
19
+ from pxa_security import (
20
+ decrypt_credential,
21
+ decrypt_userinfo,
22
+ encrypt_credential,
23
+ encrypt_userinfo,
24
+ )
25
+
26
+ USERINFO_KEY = "0123456789abcdef" # 16 bytes -> AES-128
27
+ CREDENTIAL_MASTER_KEY = "test-only-master-key-with-enough-entropy"
28
+
29
+
30
+ # --- credential API -------------------------------------------------------
31
+
32
+ def test_credential_round_trip():
33
+ token = encrypt_credential(CREDENTIAL_MASTER_KEY, "s3cret!")
34
+ assert decrypt_credential(CREDENTIAL_MASTER_KEY, token) == "s3cret!"
35
+
36
+
37
+ def test_credential_round_trip_unicode():
38
+ token = encrypt_credential(CREDENTIAL_MASTER_KEY, "비밀번호#1")
39
+ assert decrypt_credential(CREDENTIAL_MASTER_KEY, token) == "비밀번호#1"
40
+
41
+
42
+ def test_credential_token_is_ascii_str():
43
+ token = encrypt_credential(CREDENTIAL_MASTER_KEY, "s3cret!")
44
+ assert isinstance(token, str)
45
+ token.encode("ascii") # must not raise
46
+
47
+
48
+ def test_credential_decrypt_accepts_bytes_token():
49
+ token = encrypt_credential(CREDENTIAL_MASTER_KEY, "s3cret!")
50
+ assert (
51
+ decrypt_credential(CREDENTIAL_MASTER_KEY, token.encode("ascii"))
52
+ == "s3cret!"
53
+ )
54
+
55
+
56
+ def test_credential_wrong_master_key_raises_value_error():
57
+ token = encrypt_credential(CREDENTIAL_MASTER_KEY, "s3cret!")
58
+ with pytest.raises(ValueError, match="decryption failed"):
59
+ decrypt_credential("different-master-key-that-is-long-enough", token)
60
+
61
+
62
+ def test_credential_corrupted_token_raises_value_error():
63
+ with pytest.raises(ValueError, match="decryption failed"):
64
+ decrypt_credential(CREDENTIAL_MASTER_KEY, "not-a-valid-token!")
65
+
66
+
67
+ def test_credential_tampering_is_detected():
68
+ token = encrypt_credential(CREDENTIAL_MASTER_KEY, "s3cret!")
69
+ position = len(token) // 2
70
+ replacement = "A" if token[position] != "A" else "B"
71
+ tampered = token[:position] + replacement + token[position + 1 :]
72
+ with pytest.raises(ValueError, match="decryption failed"):
73
+ decrypt_credential(CREDENTIAL_MASTER_KEY, tampered)
74
+
75
+
76
+ def test_credential_tokens_differ_between_calls():
77
+ assert encrypt_credential(CREDENTIAL_MASTER_KEY, "pw") != encrypt_credential(
78
+ CREDENTIAL_MASTER_KEY, "pw"
79
+ )
80
+
81
+
82
+ def test_credential_rejects_empty_master_key():
83
+ with pytest.raises(ValueError, match="must not be empty"):
84
+ encrypt_credential("", "pw")
85
+
86
+
87
+ def test_credential_short_master_key_works():
88
+ token = encrypt_credential("public-username", "pw")
89
+ assert decrypt_credential("public-username", token) == "pw"
90
+
91
+
92
+ # --- userinfo API ---------------------------------------------------------
93
+
94
+ def test_userinfo_round_trip_dict():
95
+ userinfo = {"name": "홍길동", "dept": "IT", "email": "hong@example.com"}
96
+ data = encrypt_userinfo(userinfo, USERINFO_KEY)
97
+ assert decrypt_userinfo(data, USERINFO_KEY) == userinfo
98
+
99
+
100
+ def test_userinfo_ciphertext_is_hex():
101
+ data = encrypt_userinfo({"a": "b"}, USERINFO_KEY)
102
+ bytes.fromhex(data) # must not raise
103
+
104
+
105
+ def test_userinfo_accepts_legacy_plaintext_string():
106
+ # Legacy callers pass a ready-made JSON string with MIME-encoded values
107
+ plaintext = json.dumps({"name": "=?UTF-8?B?7ZmN6ri464+Z?="})
108
+ data = encrypt_userinfo(plaintext, USERINFO_KEY)
109
+ assert decrypt_userinfo(data, USERINFO_KEY) == {"name": "홍길동"}
110
+
111
+
112
+ def test_userinfo_plain_values_pass_through_decode():
113
+ # Values without the MIME prefix are returned as-is (node decode())
114
+ data = encrypt_userinfo(json.dumps({"name": "plain", "age": 30}), USERINFO_KEY)
115
+ assert decrypt_userinfo(data, USERINFO_KEY) == {"name": "plain", "age": 30}
116
+
117
+
118
+ def test_userinfo_known_vector_from_legacy_implementation():
119
+ # Generated with the original PyCryptodome port of the Node.js code
120
+ # (AES-128-CBC, iv = key), to lock in wire-format compatibility.
121
+ legacy_hex = (
122
+ "ed958be76ed1bfa41e194d5545e9f46220c916330966fa761d50dbb7d63bd859"
123
+ "ec3f729ce9abdab0976dcb2342a481c7"
124
+ )
125
+ assert decrypt_userinfo(legacy_hex, USERINFO_KEY) == {"name": "홍길동"}
126
+
127
+
128
+ def test_userinfo_encryption_is_deterministic():
129
+ # iv = key (legacy format), so identical inputs yield identical output
130
+ assert encrypt_userinfo({"a": "b"}, USERINFO_KEY) == encrypt_userinfo(
131
+ {"a": "b"}, USERINFO_KEY
132
+ )
133
+
134
+
135
+ def test_userinfo_wrong_key_raises_value_error():
136
+ data = encrypt_userinfo({"a": "b"}, USERINFO_KEY)
137
+ with pytest.raises(ValueError, match="decryption failed"):
138
+ decrypt_userinfo(data, "fedcba9876543210")
139
+
140
+
141
+ def test_userinfo_corrupted_data_raises_value_error():
142
+ with pytest.raises(ValueError, match="decryption failed"):
143
+ decrypt_userinfo("not-hex-data", USERINFO_KEY)
144
+
145
+
146
+ def test_userinfo_key_must_be_16_bytes():
147
+ with pytest.raises(ValueError, match="16 bytes"):
148
+ encrypt_userinfo({"a": "b"}, "short-key")