├── .editorconfig ├── .github ├── CONTRIBUTING.md ├── dependabot.yml └── workflows │ ├── smokeshow.yml │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── infisical ├── __init__.py ├── __version__.py ├── api │ ├── __init__.py │ ├── create_secret.py │ ├── delete_secret.py │ ├── get_secret.py │ ├── get_secrets.py │ ├── get_service_token_data.py │ ├── get_service_token_data_key.py │ ├── models.py │ └── update_secret.py ├── client │ ├── __init__.py │ └── infisicalclient.py ├── constants.py ├── exceptions │ └── __init__.py ├── helpers │ ├── __init__.py │ ├── client.py │ └── secrets.py ├── logger.py ├── models │ ├── __init__.py │ ├── api.py │ ├── models.py │ └── secret_service.py ├── services │ ├── __init__.py │ └── secret_service.py └── utils │ ├── __init__.py │ ├── crypto.py │ └── http.py ├── pyproject.toml ├── scripts ├── format.sh ├── lint.sh └── test.sh └── tests ├── __init__.py ├── test_client ├── __init__.py └── test_infisical_client.py └── test_infisical.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | charset = utf-8 11 | indent_size = 4 12 | 13 | [*.py] 14 | indent_size = 4 15 | 16 | [*.{json,yml,toml}] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Start by cloning the repository: 2 | ```console 3 | $ git clone https://github.com/Infisical/infisical-python 4 | $ cd infisical-python 5 | ``` 6 | 7 | We recommand that you create a virtual environment: 8 | ```console 9 | $ python -m venv env 10 | ``` 11 | 12 | Then activate the environment with: 13 | ```console 14 | # For linux 15 | $ source ./env/bin/activate 16 | 17 | # For Windows PowerShell 18 | $ .\env\Scripts\Activate.ps1 19 | ``` 20 | 21 | Make sure that you have the latest version of `pip` to avoid errors on the next step: 22 | ```console 23 | $ python -m pip install --upgrade pip 24 | ``` 25 | 26 | Then install the project in editable mode and the dependencies with: 27 | ```console 28 | $ pip install -e '.[dev,test]' 29 | ``` 30 | 31 | To run all the tests you can use the following command: 32 | ```console 33 | $ pytest tests 34 | ``` 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | commit-message: 9 | prefix: ⬆ 10 | # Python 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | commit-message: 16 | prefix: ⬆ 17 | -------------------------------------------------------------------------------- /.github/workflows/smokeshow.yml: -------------------------------------------------------------------------------- 1 | name: Smokeshow 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Test] 6 | types: [completed] 7 | 8 | permissions: 9 | statuses: write 10 | 11 | jobs: 12 | smokeshow: 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.9" 20 | 21 | - run: pip install smokeshow 22 | 23 | - uses: dawidd6/action-download-artifact@v2.26.0 24 | with: 25 | workflow: test.yml 26 | commit: ${{ github.event.workflow_run.head_sha }} 27 | 28 | - run: smokeshow upload coverage-html 29 | env: 30 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} 31 | # Threshold disable at the beginning, should be updated along with coverage progress 32 | SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 0 33 | SMOKESHOW_GITHUB_CONTEXT: coverage 34 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 36 | SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | cache: "pip" 25 | cache-dependency-path: pyproject.toml 26 | - uses: actions/cache@v3 27 | id: cache 28 | with: 29 | path: ${{ env.pythonLocation }} 30 | key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-test-v03 31 | - name: Install Dependencies 32 | if: steps.cache.outputs.cache-hit != 'true' 33 | run: pip install -e .[dev,test] 34 | - name: Lint 35 | run: bash scripts/lint.sh 36 | - run: mkdir coverage 37 | - name: Test 38 | run: bash scripts/test.sh 39 | env: 40 | COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }} 41 | CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }} 42 | - name: Store coverage files 43 | uses: actions/upload-artifact@v3 44 | with: 45 | name: coverage 46 | path: coverage 47 | coverage-combine: 48 | needs: [test] 49 | runs-on: ubuntu-latest 50 | 51 | steps: 52 | - uses: actions/checkout@v3 53 | 54 | - uses: actions/setup-python@v4 55 | with: 56 | python-version: "3.8" 57 | cache: "pip" 58 | cache-dependency-path: pyproject.toml 59 | 60 | - name: Get coverage files 61 | uses: actions/download-artifact@v3 62 | with: 63 | name: coverage 64 | path: coverage 65 | 66 | - run: pip install coverage[toml] 67 | 68 | - run: ls -la coverage 69 | - run: coverage combine coverage 70 | - run: coverage report 71 | - run: coverage html --show-contexts --title "Coverage for ${{ github.sha }}" 72 | 73 | - name: Store coverage HTML 74 | uses: actions/upload-artifact@v3 75 | with: 76 | name: coverage-html 77 | path: htmlcov 78 | 79 | # https://github.com/marketplace/actions/alls-green#why 80 | check: # This job does nothing and is only used for the branch protection 81 | if: always() 82 | 83 | needs: 84 | - coverage-combine 85 | 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - name: Decide whether the needed jobs succeeded or failed 90 | uses: re-actors/alls-green@release/v1 91 | with: 92 | jobs: ${{ toJSON(needs) }} 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/windows,linux,python,visualstudiocode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,python,visualstudiocode 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### Python ### 21 | # Byte-compiled / optimized / DLL files 22 | __pycache__/ 23 | *.py[cod] 24 | *$py.class 25 | 26 | # C extensions 27 | *.so 28 | 29 | # Distribution / packaging 30 | .Python 31 | build/ 32 | develop-eggs/ 33 | dist/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | sdist/ 41 | var/ 42 | wheels/ 43 | share/python-wheels/ 44 | *.egg-info/ 45 | .installed.cfg 46 | *.egg 47 | MANIFEST 48 | 49 | # PyInstaller 50 | # Usually these files are written by a python script from a template 51 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 52 | *.manifest 53 | *.spec 54 | 55 | # Installer logs 56 | pip-log.txt 57 | pip-delete-this-directory.txt 58 | 59 | # Unit test / coverage reports 60 | htmlcov/ 61 | .tox/ 62 | .nox/ 63 | .coverage 64 | .coverage.* 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | *.cover 69 | *.py,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | cover/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Django stuff: 79 | *.log 80 | local_settings.py 81 | db.sqlite3 82 | db.sqlite3-journal 83 | 84 | # Flask stuff: 85 | instance/ 86 | .webassets-cache 87 | 88 | # Scrapy stuff: 89 | .scrapy 90 | 91 | # Sphinx documentation 92 | docs/_build/ 93 | 94 | # PyBuilder 95 | .pybuilder/ 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # IPython 102 | profile_default/ 103 | ipython_config.py 104 | 105 | # pyenv 106 | # For a library or package, you might want to ignore these files since the code is 107 | # intended to run in multiple environments; otherwise, check them in: 108 | # .python-version 109 | 110 | # pipenv 111 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 112 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 113 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 114 | # install all needed dependencies. 115 | #Pipfile.lock 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Ruff 152 | .ruff_cache/ 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | ### Windows ### 164 | # Windows thumbnail cache files 165 | Thumbs.db 166 | Thumbs.db:encryptable 167 | ehthumbs.db 168 | ehthumbs_vista.db 169 | 170 | # Dump file 171 | *.stackdump 172 | 173 | # Folder config file 174 | [Dd]esktop.ini 175 | 176 | # Recycle Bin used on file shares 177 | $RECYCLE.BIN/ 178 | 179 | # Windows Installer files 180 | *.cab 181 | *.msi 182 | *.msix 183 | *.msm 184 | *.msp 185 | 186 | # Windows shortcuts 187 | *.lnk 188 | 189 | ### VisualStudioCode ### 190 | .vscode/* 191 | !.vscode/settings.json 192 | !.vscode/tasks.json 193 | !.vscode/launch.json 194 | !.vscode/extensions.json 195 | *.code-workspace 196 | 197 | # Local History for Visual Studio Code 198 | .history/ 199 | 200 | ### VisualStudioCode Patch ### 201 | # Ignore all local history of files 202 | .history 203 | .ionide 204 | 205 | # End of https://www.toptal.com/developers/gitignore/api/windows,linux,python,visualstudiocode 206 | 207 | .infisical.json 208 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.enabled": true, 3 | "python.formatting.provider": "none", 4 | "editor.formatOnSave": true, 5 | "python.linting.mypyEnabled": true, 6 | "[python]": { 7 | "editor.codeActionsOnSave": { 8 | "source.organizeImports": true 9 | }, 10 | "editor.defaultFormatter": "ms-python.black-formatter" 11 | }, 12 | "ruff.organizeImports": false, 13 | "ruff.fixAll": false, 14 | "ruff.importStrategy": "fromEnvironment", 15 | "autoDocstring.docstringFormat": "sphinx-notypes", 16 | "python.analysis.typeCheckingMode": "strict" 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes will be documented in this file. 4 | 5 | ## [1.5.0] - 2023-10-01 6 | 7 | This version adds support for the Service Token V3 (Beta) authentication method for Infisical which is a JSON; note that it continues to support Service Token V2 (the default authentication method at this time). With this update, it's possible to initialize the InfisicalClient with a Service Token V3 JSON via the `tokenJSON` parameter to perform CRUD secret operations. 8 | 9 | Example: 10 | 11 | ``` 12 | client = InfisicalClient( 13 | token_json=os.environ.get("INFISICAL_TOKEN_JSON") 14 | ) 15 | ``` 16 | 17 | ## [1.4.3] - 2023-09-13 18 | 19 | This version adds support for the `include_imports` and `attach_to_os_environ` parameters for the `get_all_secrets()` method. 20 | 21 | ## [1.4.2] - 2023-08-27 22 | 23 | This version patches the `path` parameter in `get_all_secrets()` so you can now fetch all secrets from a specific path. 24 | 25 | 26 | ## [1.4.1] - 2023-08-21 27 | 28 | This version updates returning unfound secrets to whatever is present on `os.environ` as opposed to returning `None`. 29 | 30 | ## [1.4.0] - 2023-07-13 31 | 32 | This version adds support for folders or path-based secret storage for all secret CRUD operations. 33 | 34 | ## [1.3.0] - 2023-05-05 35 | 36 | This version adds support for generating a symmetric encryption key, symmetric encryption, and decryption; algorithm used is `aes-256-gcm` with 96-bit `iv`. 37 | 38 | - `create_symmetric_key()`: Method to create a base64-encoded, 256-bit symmetric key. 39 | - `encrypt_symmetric()`: Method to symmetrically encrypt plaintext using the symmetric key. 40 | - `decrypt_symmetric()`: Method to symmetrically decrypt ciphertext using the symmetric key. 41 | 42 | To simplify things for developers, we stick to `base64` encoding and convert to and from bytes inside the methods. 43 | 44 | ## [1.2.0] - 2023-05-01 45 | 46 | Patched `expires_at` on `GetServiceTokenDetailsResponse` to be optional (to accomodate for cases where the service token never expires). 47 | 48 | ## [1.1.0] - 2023-04-27 49 | 50 | This version adds support for querying and mutating secrets by name with the introduction of blind-indexing. It also adds support for caching by passing in `cache_ttl`. 51 | 52 | - `get_all_secrets()`: Method to get all secrets from a project and environment 53 | - `create_secret()`: Method to create a secret 54 | - `get_secret()`: Method to get a secret by name 55 | - `update_secret()`: Method to update a secret by name 56 | - `delete_secret()`: Method to delete a secret by name 57 | 58 | The format of any fetched secrets from the SDK is now a `SecretBundle` that has useful properties like `secret_name`, `secret_value`, and `version`. 59 | 60 | This version also deprecates the `connect()` and `create_connection()` methods in favor of initializing the SDK with `new InfisicalClient(options)` 61 | 62 | It also includes some tests that can be run by passing in a `INFISICAL_TOKEN` and `SITE_URL` as environment variables to point the test client to an instance of Infisical. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yohann MARTIN 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | infisical 4 | 5 |

6 |

7 |

Open-source, end-to-end encrypted tool to manage secrets and configs across your team and infrastructure.

8 |

9 | 10 | # Deprecated! 11 | 12 | This is now considered a legacy SDK, as we have released a new SDK that will be receiving all future updates. [You can find it here](https://pypi.org/project/infisical-python/). 13 | 14 | # Table of Contents 15 | 16 | - [Links](#links) 17 | - [Basic Usage](#basic-usage) 18 | - [Secrets](#working-with-secrets) 19 | - [Get Secrets](#get-secrets) 20 | - [Get Secret](#get-secret) 21 | - [Create Secret](#create-secret) 22 | - [Update Secret](#update-secret) 23 | - [Delete Secret](#delete-secret) 24 | - [Cryptography](#cryptography) 25 | - [Create Symmetric Key](#create-symmetric-key) 26 | - [Encrypt Symmetric](#encrypt-symmetric) 27 | - [Decrypt Symmetric](#decrypt-symmetric) 28 | 29 | # Links 30 | 31 | - [Infisical](https://github.com/Infisical/infisical) 32 | 33 | # Basic Usage 34 | 35 | ```py 36 | from flask import Flask 37 | from infisical import InfisicalClient 38 | 39 | app = Flask(__name__) 40 | 41 | client = InfisicalClient(token="your_infisical_token") 42 | 43 | @app.route("/") 44 | def hello_world(): 45 | # access value 46 | name = client.get_secret("NAME", environment="dev", path="/") 47 | return f"Hello! My name is: {name.secret_value}" 48 | ``` 49 | 50 | This example demonstrates how to use the Infisical Python SDK with a Flask application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. 51 | 52 | It is also possible to use the SDK to encrypt/decrypt text; the implementation uses `aes-256-gcm` with components of the encryption/decryption encoded in `base64`. 53 | 54 | ```python 55 | from infisical import InfisicalClient 56 | 57 | client = InfisicalClient() 58 | 59 | # some plaintext you want to encrypt 60 | plaintext = 'The quick brown fox jumps over the lazy dog' 61 | 62 | # create a base64-encoded, 256-bit symmetric key 63 | key = client.create_symmetric_key() 64 | 65 | # encrypt 66 | ciphertext, iv, tag = client.encrypt_symmetric(plaintext, key) 67 | 68 | # decrypt 69 | cleartext = client.decrypt_symmetric(ciphertext, key, iv, tag) 70 | ``` 71 | 72 | # Installation 73 | 74 | You need Python 3.7+. 75 | 76 | ```console 77 | $ pip install infisical 78 | ``` 79 | 80 | # Configuration 81 | 82 | Import the SDK and create a client instance with your [Infisical Token](https://infisical.com/docs/getting-started/dashboard/token). 83 | 84 | ```py 85 | from infisical import InfisicalClient 86 | 87 | client = InfisicalClient(token="your_infisical_token") 88 | ``` 89 | 90 | Using Infisical Token V3 (Beta): 91 | 92 | In `v1.5.0`, we released a superior token authentication method; this credential is a JSON containing a `publicKey`, `privateKey`, and `serviceToken` and can be used to initialize the Node SDK client instead of the regular service token. 93 | 94 | You can use this beta feature like so: 95 | 96 | ```py 97 | from infisical import InfisicalClient 98 | 99 | client = InfisicalClient(token_json="your_infisical_token_v3_json") 100 | ``` 101 | 102 | ### Options 103 | 104 | | Parameter | Type | Description | 105 | | ----------- | --------- | --------------------------------------------------------------------------- | 106 | | `token` | `string` | An Infisical Token scoped to a project and environment(s). | 107 | | `tokenJson` | `string` | An Infisical Token V3 JSON scoped to a project and environment(s) - in beta | 108 | | `site_url` | `string` | Your self-hosted Infisical site URL. Default: `https://app.infisical.com`. | 109 | | `cache_ttl` | `number` | Time-to-live (in seconds) for refreshing cached secrets. Default: `300`. | 110 | | `debug` | `boolean` | Turns debug mode on or off. Default: `false`. | 111 | 112 | ### Caching 113 | 114 | The SDK caches every secret and updates it periodically based on the provided `cache_ttl`. For example, if `cache_ttl` of `300` is provided, then a secret will be refetched 5 minutes after the first fetch; if the fetch fails, the cached secret is returned. 115 | 116 | # Secrets 117 | 118 | ## Get Secrets 119 | 120 | ```py 121 | secrets = client.get_all_secrets(environment="dev", path="/foo/bar/") 122 | ``` 123 | 124 | Retrieve all secrets within a given environment and folder path. The service token used must have access to the given path and environment. 125 | 126 | ### Parameters 127 | 128 | - `environment` (string): The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. 129 | - `path` (string): The path from where secrets should be fetched from. 130 | - `include_imports` (boolean): Whether or not to include imported secrets from the current path. Read about [secret import](https://infisical.com/docs/documentation/platform/secret-reference#import-entire-folders). If not specified, the default value is `True`. 131 | - `attach_to_os_environ` (boolean): Whether or not to attach fetched secrets to `os.environ`. If not specified, the default value is `False`. 132 | 133 | ## Get Secret 134 | 135 | ```py 136 | secret = client.get_secret("API_KEY", environment="dev", path="/") 137 | value = secret.secret_value # get its value 138 | ``` 139 | 140 | By default, `get_secret()` fetches and returns a personal secret. If not found, it returns a shared secret, or tries to retrieve the value from `os.environ`. If a secret is fetched, `get_secret()` caches it to reduce excessive calls and re-fetches periodically based on the `cacheTTL` option (default is 300 seconds) when initializing the client — for more information, see the caching section. 141 | 142 | To explicitly retrieve a shared secret: 143 | 144 | ```py 145 | secret = client.get_secret(secret_name="API_KEY", type="shared", environment="dev", path="/") 146 | value = secret.secret_value # get its value 147 | ``` 148 | 149 | ### Parameters 150 | 151 | - `secret_name` (string): The key of the secret to retrieve. 152 | - `environment` (string): The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. 153 | - `path` (string): The path from where secrets should be fetched from. 154 | - `type` (string, optional): The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". 155 | 156 | ## Create Secret 157 | 158 | Create a new secret in Infisical 159 | 160 | ```py 161 | new_api_key = client.create_secret("API_KEY", "FOO", environment="dev", path="/", type="shared") 162 | ``` 163 | 164 | ### Parameters 165 | 166 | - `secret_name` (string): The key of the secret to create. 167 | - `secret_value` (string): The value of the secret. 168 | - `environment` (string): The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. 169 | - `path` (string): The path from where secrets should be created. 170 | - `type` (string, optional): The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". A personal secret can only be created if a shared secret with the same name exists. 171 | 172 | ## Update Secret 173 | 174 | Update an existing secret in Infisical 175 | 176 | ```py 177 | updated_api_key = client.update_secret("API_KEY", "BAR", environment="dev", path="/", type="shared") 178 | ``` 179 | 180 | ### Parameters 181 | 182 | - `secret_name` (string): The key of the secret to update. 183 | - `secret_value` (string): The new value of the secret. 184 | - `environment` (string): The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. 185 | - `path` (string): The path from where secrets should be updated. 186 | - `type` (string, optional): The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". 187 | 188 | ## Delete Secret 189 | 190 | Delete a secret in Infisical 191 | 192 | ```py 193 | deleted_secret = client.delete_secret("API_KEY", environment="dev", path="/", type="shared") 194 | ``` 195 | 196 | ### Parameters 197 | 198 | - `secret_name` (string): The key of the secret to delete. 199 | - `environment` (string): The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. 200 | - `path` (string): The path from where secrets should be deleted. 201 | - `type` (string, optional): The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". 202 | 203 | # Cryptography 204 | 205 | ## Create Symmetric Key 206 | 207 | Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. 208 | 209 | ```python 210 | key = client.create_symmetric_key() 211 | ``` 212 | 213 | ### Returns 214 | 215 | `key` (string): A base64-encoded, 256-bit symmetric key. 216 | 217 | ## Encrypt Symmetric 218 | 219 | Encrypt plaintext -> ciphertext. 220 | 221 | ```python 222 | ciphertext, iv, tag = client.encrypt_symmetric(plaintext, key) 223 | ``` 224 | 225 | ### Parameters 226 | 227 | - `plaintext` (string): The plaintext to encrypt. 228 | - `key` (string): The base64-encoded, 256-bit symmetric key to use to encrypt the `plaintext`. 229 | 230 | ### Returns 231 | 232 | - `ciphertext` (string): The base64-encoded, encrypted `plaintext`. 233 | - `iv` (string): The base64-encoded, 96-bit initialization vector generated for the encryption. 234 | - `tag` (string): The base64-encoded authentication tag generated during the encryption. 235 | 236 | ## Decrypt Symmetric 237 | 238 | Decrypt ciphertext -> plaintext/cleartext. 239 | 240 | ```python 241 | cleartext = client.decrypt_symmetric(ciphertext, key, iv, tag) 242 | ``` 243 | 244 | ### Parameters 245 | 246 | - `ciphertext` (string): The ciphertext to decrypt. 247 | - `key` (string): The base64-encoded, 256-bit symmetric key to use to decrypt the `ciphertext`. 248 | - `iv` (string): The base64-encoded, 96-bit initiatlization vector generated for the encryption. 249 | - `tag` (string): The base64-encoded authentication tag generated during encryption. 250 | 251 | ### Returns 252 | 253 | `cleartext` (string): The decrypted encryption that is the cleartext/plaintext. 254 | 255 | # Contributing 256 | 257 | Bug fixes, docs, and library improvements are always welcome. Please refer to our [Contributing Guide](https://infisical.com/docs/contributing/overview) for detailed information on how you can contribute. 258 | 259 | [//]: contributor-faces 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | ## Getting Started 268 | 269 | If you want to familiarize yourself with the SDK, you can start by [forking the repository](https://docs.github.com/en/get-started/quickstart/fork-a-repo) and [cloning it in your local development environment](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository). 270 | 271 | After cloning the repository, we recommend that you create a virtual environment: 272 | 273 | ```console 274 | $ python -m venv env 275 | ``` 276 | 277 | Then activate the environment with: 278 | 279 | ```console 280 | # For linux 281 | $ source ./env/bin/activate 282 | 283 | # For Windows PowerShell 284 | $ .\env\Scripts\Activate.ps1 285 | ``` 286 | 287 | Make sure that you have the latest version of `pip` to avoid errors on the next step: 288 | 289 | ```console 290 | $ python -m pip install --upgrade pip 291 | ``` 292 | 293 | Then install the project in editable mode and the dependencies with: 294 | 295 | ```console 296 | $ pip install -e '.[dev,test]' 297 | ``` 298 | 299 | To run existing tests, you need to make a `.env` at the root of this project containing a `INFISICAL_TOKEN` and `SITE_URL`. This will execute the tests against a project and environment scoped to the `INFISICAL_TOKEN` on a running instance of Infisical at the `SITE_URL` (this could be [Infisical Cloud](https://app.infisical.com)). 300 | 301 | To run all the tests you can use the following command: 302 | 303 | ```console 304 | $ pytest tests 305 | ``` 306 | 307 | # License 308 | 309 | `infisical-python` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 310 | -------------------------------------------------------------------------------- /infisical/__init__.py: -------------------------------------------------------------------------------- 1 | from .client.infisicalclient import InfisicalClient 2 | 3 | __all__ = ["InfisicalClient"] 4 | -------------------------------------------------------------------------------- /infisical/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.4.3" 2 | -------------------------------------------------------------------------------- /infisical/api/__init__.py: -------------------------------------------------------------------------------- 1 | from infisical.__version__ import __version__ 2 | from infisical.utils.http import BaseUrlSession, get_http_client 3 | 4 | USER_AGENT = f"InfisicalPythonSDK/{__version__}" 5 | 6 | 7 | def create_api_request_with_auth(base_url: str, service_token: str) -> BaseUrlSession: 8 | """Returns a :class:`requests.Session` with a ``base_url`` and the authorization 9 | bearer set to the ``service_token``. 10 | 11 | :param base_url: The base url to use 12 | :param service_token: The service token to use as a authorization bearer token 13 | :return: A :class:`requests.Session` instance preconfigured 14 | """ 15 | api_request = get_http_client(base_url=base_url.rstrip("/")) 16 | 17 | api_request.headers.update({"User-Agent": USER_AGENT}) 18 | api_request.headers.update({"Content-Type": "application/json"}) 19 | api_request.headers.update({"Authorization": f"Bearer {service_token}"}) 20 | 21 | return api_request 22 | -------------------------------------------------------------------------------- /infisical/api/create_secret.py: -------------------------------------------------------------------------------- 1 | from infisical.models.api import CreateSecretDTO, SecretResponse 2 | from requests import Session 3 | 4 | 5 | def create_secret_req(api_request: Session, options: CreateSecretDTO) -> SecretResponse: 6 | response = api_request.post( 7 | url=f"/api/v3/secrets/{options.secret_name}", 8 | json={ 9 | "workspaceId": options.workspace_id, 10 | "environment": options.environment, 11 | "type": options.type, 12 | "secretKeyCiphertext": options.secret_key_ciphertext, 13 | "secretKeyIV": options.secret_key_iv, 14 | "secretKeyTag": options.secret_key_tag, 15 | "secretValueCiphertext": options.secret_value_ciphertext, 16 | "secretValueIV": options.secret_value_iv, 17 | "secretValueTag": options.secret_value_tag, 18 | "secretPath": options.path, 19 | }, 20 | ) 21 | 22 | json_object = response.json() 23 | 24 | json_object["secret"]["workspace"] = options.workspace_id 25 | json_object["secret"]["environment"] = options.environment 26 | 27 | 28 | 29 | return SecretResponse.parse_obj(json_object) 30 | -------------------------------------------------------------------------------- /infisical/api/delete_secret.py: -------------------------------------------------------------------------------- 1 | from infisical.models.api import DeleteSecretDTO, SecretResponse 2 | from requests import Session 3 | 4 | 5 | def delete_secret_req(api_request: Session, options: DeleteSecretDTO) -> SecretResponse: 6 | response = api_request.delete( 7 | url=f"/api/v3/secrets/{options.secret_name}", 8 | json={ 9 | "workspaceId": options.workspace_id, 10 | "environment": options.environment, 11 | "type": options.type, 12 | "secretPath": options.path, 13 | }, 14 | ) 15 | 16 | json_object = response.json() 17 | 18 | json_object["secret"]["workspace"] = options.workspace_id 19 | json_object["secret"]["environment"] = options.environment 20 | 21 | return SecretResponse.parse_obj(json_object) 22 | -------------------------------------------------------------------------------- /infisical/api/get_secret.py: -------------------------------------------------------------------------------- 1 | from infisical.models.api import GetSecretDTO, SecretResponse 2 | from requests import Session 3 | 4 | 5 | def get_secret_req(api_request: Session, options: GetSecretDTO) -> SecretResponse: 6 | response = api_request.get( 7 | url=f"/api/v3/secrets/{options.secret_name}", 8 | params={ 9 | "workspaceId": options.workspace_id, 10 | "environment": options.environment, 11 | "type": options.type, 12 | "secretPath": options.path, 13 | }, 14 | ) 15 | 16 | json_object = response.json() 17 | 18 | json_object["secret"]["workspace"] = options.workspace_id 19 | json_object["secret"]["environment"] = options.environment 20 | 21 | return SecretResponse.parse_obj(json_object) 22 | -------------------------------------------------------------------------------- /infisical/api/get_secrets.py: -------------------------------------------------------------------------------- 1 | from infisical.models.api import GetSecretsDTO, SecretsResponse 2 | from requests import Session 3 | 4 | 5 | def get_secrets_req(api_request: Session, options: GetSecretsDTO) -> SecretsResponse: 6 | """Send request again Infisical API to fetch secrets. 7 | See more information on https://infisical.com/docs/api-reference/endpoints/secrets/read 8 | 9 | :param api_request: The :class:`requests.Session` instance used to perform the request 10 | :param workspace_id: The ID of the workspace 11 | :param environment: The environment 12 | :return: Returns the API response as-is 13 | """ 14 | 15 | response = api_request.get( 16 | "/api/v3/secrets", 17 | params={ 18 | "environment": options.environment, 19 | "workspaceId": options.workspace_id, 20 | "secretPath": options.path, 21 | "include_imports": str(options.include_imports).lower() 22 | }, 23 | ) 24 | 25 | json_object = response.json() 26 | 27 | for obj in json_object['secrets']: 28 | obj["workspace"] = options.workspace_id 29 | obj["environment"] = options.environment 30 | 31 | for obj in json_object['imports']: 32 | for obj in obj['secrets']: 33 | obj["workspace"] = options.workspace_id 34 | obj["environment"] = options.environment 35 | 36 | data = SecretsResponse.parse_obj(json_object) 37 | 38 | return (data.secrets if data.secrets else [], data.imports if data.imports else []) 39 | 40 | -------------------------------------------------------------------------------- /infisical/api/get_service_token_data.py: -------------------------------------------------------------------------------- 1 | from infisical.api.models import GetServiceTokenDetailsResponse 2 | from requests import Session 3 | 4 | 5 | def get_service_token_data_req( 6 | api_request: Session, 7 | ) -> GetServiceTokenDetailsResponse: 8 | """Send request again Infisical API to fetch service token data. 9 | See more information on https://infisical.com/docs/api-reference/endpoints/service-tokens/get 10 | 11 | :param api_request: The :class:`requests.Session` instance used to perform the request 12 | :return: Returns the API response as-is 13 | """ 14 | response = api_request.get("/api/v2/service-token") 15 | 16 | return GetServiceTokenDetailsResponse.parse_obj(response.json()) 17 | -------------------------------------------------------------------------------- /infisical/api/get_service_token_data_key.py: -------------------------------------------------------------------------------- 1 | from infisical.api.models import GetServiceTokenKeyResponse 2 | from requests import Session 3 | 4 | 5 | def get_service_token_data_key_req( 6 | api_request: Session, 7 | ) -> GetServiceTokenKeyResponse: 8 | """Send request again Infisical API to fetch service token data v3 key. 9 | 10 | :param api_request: The :class:`requests.Session` instance used to perform the request 11 | :return: Returns the API response as-is 12 | """ 13 | response = api_request.get("/api/v3/service-token/me/key") 14 | 15 | return GetServiceTokenKeyResponse.parse_obj(response.json()) 16 | -------------------------------------------------------------------------------- /infisical/api/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class GetServiceTokenDetailsResponse(BaseModel): 8 | id: str = Field(..., alias="id") 9 | name: str 10 | workspace: str = Field(..., alias="projectId") 11 | expires_at: Optional[datetime] = Field(None, alias="expiresAt") 12 | encrypted_key: str = Field(..., alias="encryptedKey") 13 | iv: str 14 | tag: str 15 | created_at: datetime = Field(..., alias="createdAt") 16 | updated_at: datetime = Field(..., alias="updatedAt") 17 | #v: int = Field(..., alias="__v") 18 | 19 | class KeyData(BaseModel): 20 | id: str = Field(..., alias="id") 21 | workspace: str 22 | encrypted_key: str = Field(..., alias="encryptedKey") 23 | public_key: str = Field(..., alias="publicKey") 24 | nonce: str 25 | 26 | class GetServiceTokenKeyResponse(BaseModel): 27 | key: KeyData -------------------------------------------------------------------------------- /infisical/api/update_secret.py: -------------------------------------------------------------------------------- 1 | from infisical.models.api import SecretResponse, UpdateSecretDTO 2 | from requests import Session 3 | 4 | 5 | def update_secret_req(api_request: Session, options: UpdateSecretDTO) -> SecretResponse: 6 | response = api_request.patch( 7 | url=f"/api/v3/secrets/{options.secret_name}", 8 | json={ 9 | "workspaceId": options.workspace_id, 10 | "environment": options.environment, 11 | "type": options.type, 12 | "secretValueCiphertext": options.secret_value_ciphertext, 13 | "secretValueIV": options.secret_value_iv, 14 | "secretValueTag": options.secret_value_tag, 15 | "secretPath": options.path, 16 | }, 17 | ) 18 | 19 | json_object = response.json() 20 | 21 | json_object["secret"]["workspace"] = options.workspace_id 22 | json_object["secret"]["environment"] = options.environment 23 | 24 | return SecretResponse.parse_obj(json_object) 25 | -------------------------------------------------------------------------------- /infisical/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infisical/infisical-python/28bf675217ae8713b5480974b195e7a62847fe3d/infisical/client/__init__.py -------------------------------------------------------------------------------- /infisical/client/infisicalclient.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Optional 3 | 4 | from infisical.api import create_api_request_with_auth 5 | from infisical.constants import ( 6 | AUTH_MODE_SERVICE_TOKEN, 7 | AUTH_MODE_SERVICE_TOKEN_V3, 8 | INFISICAL_URL, 9 | SERVICE_TOKEN_REGEX, 10 | ) 11 | from infisical.exceptions import InfisicalTokenError 12 | from infisical.helpers.client import ( 13 | create_secret_helper, 14 | delete_secret_helper, 15 | get_all_secrets_helper, 16 | get_secret_helper, 17 | update_secret_helper, 18 | ) 19 | from infisical.models.models import SecretBundle 20 | from infisical.models.secret_service import ClientConfig 21 | from infisical.utils.crypto import ( 22 | create_symmetric_key_helper, 23 | decrypt_symmetric_helper, 24 | encrypt_symmetric_helper, 25 | ) 26 | from typing_extensions import Literal 27 | 28 | 29 | class InfisicalClient: 30 | def __init__( 31 | self, 32 | token: Optional[str] = None, 33 | token_json: Optional[str] = None, 34 | site_url: str = INFISICAL_URL, 35 | debug: bool = False, 36 | cache_ttl: int = 300, 37 | ): 38 | self.cache: Dict[str, SecretBundle] = {} 39 | self.client_config: Optional[ClientConfig] = None 40 | 41 | if token and token != "": 42 | token_match = SERVICE_TOKEN_REGEX.fullmatch(token) 43 | 44 | if token_match is None: 45 | raise InfisicalTokenError("The token is not in correct format!") 46 | 47 | service_token = token_match.group(1) 48 | service_token_key = token_match.group(2) 49 | 50 | self.client_config = ClientConfig( 51 | auth_mode=AUTH_MODE_SERVICE_TOKEN, 52 | credentials={"service_token_key": service_token_key}, 53 | cache_ttl=cache_ttl, 54 | ) 55 | 56 | self.api_request = create_api_request_with_auth(site_url, service_token) 57 | 58 | if token_json and token_json != "": 59 | token_dict = json.loads(token_json) 60 | 61 | self.client_config = ClientConfig( 62 | auth_mode=AUTH_MODE_SERVICE_TOKEN_V3, 63 | credentials={ 64 | "public_key": token_dict["publicKey"], 65 | "private_key": token_dict["privateKey"] 66 | }, 67 | cache_ttl=cache_ttl 68 | ) 69 | 70 | self.api_request = create_api_request_with_auth(site_url, token_dict["serviceToken"]) 71 | 72 | self.debug = debug 73 | 74 | print("WARNING: You are using a deprecated version of the Infisical SDK. Please use the new Infisical SDK found here: https://pypi.org/project/infisical-python/") 75 | 76 | def get_all_secrets( 77 | self, 78 | environment: str = "dev", 79 | path: str = "/", 80 | include_imports: bool = True, 81 | attach_to_os_environ: bool = False 82 | ): 83 | """Return all the secrets accessible by the instance of Infisical""" 84 | return get_all_secrets_helper(self, environment, path, include_imports, attach_to_os_environ) 85 | 86 | def get_secret( 87 | self, 88 | secret_name: str, 89 | type: Literal["shared", "personal"] = "personal", 90 | environment: str = "dev", 91 | path: str = "/", 92 | ) -> SecretBundle: 93 | """Return secret with name `secret_name` 94 | 95 | :param secret_name: Key of secret 96 | :param type: Type of secret that is either "shared" or "personal" 97 | :return: Secret bundle for secret with name `secret_name` 98 | """ 99 | return get_secret_helper(self, secret_name, type, environment, path) 100 | 101 | def create_secret( 102 | self, 103 | secret_name: str, 104 | secret_value: str, 105 | type: Literal["shared", "personal"] = "shared", 106 | environment: str = "dev", 107 | path: str = "/", 108 | ) -> SecretBundle: 109 | """Create secret with name `secret_name` and value `secret_value` 110 | 111 | :param secret_name: Name of secret to create 112 | :param secret_value: Value of secret to create 113 | :param type: Type of secret to create that is either "shared" or "personal" 114 | :return: Secret bundle for created secret with name `secret_name` 115 | """ 116 | return create_secret_helper( 117 | self, secret_name, secret_value, type, environment, path 118 | ) 119 | 120 | def update_secret( 121 | self, 122 | secret_name: str, 123 | secret_value: str, 124 | type: Literal["shared", "personal"] = "shared", 125 | environment: str = "dev", 126 | path: str = "/", 127 | ) -> SecretBundle: 128 | """Update secret with name `secret_name` and value `secret_value` 129 | 130 | :param secret_name: Name of secret to update 131 | :param secret_value: New value of secret to update 132 | :param type: Type of secret to update that is either "shared" or "personal" 133 | :return: Secret bundle for updated secret with name `secret_name` 134 | """ 135 | return update_secret_helper( 136 | self, secret_name, secret_value, type, environment, path 137 | ) 138 | 139 | def delete_secret( 140 | self, 141 | secret_name: str, 142 | type: Literal["shared", "personal"] = "shared", 143 | environment: str = "dev", 144 | path: str = "/", 145 | ): 146 | """Delete secret with name `secret_name` 147 | 148 | :param secret_name: Name of secret to delete 149 | :param type: Type of secret to update that is either "shared" or "personal" 150 | :return: Secret bundle for updated secret with name `secret_name` 151 | """ 152 | return delete_secret_helper(self, secret_name, type, environment, path) 153 | 154 | def create_symmetric_key(self) -> str: 155 | """Create a base64-encoded, 256-bit symmetric key""" 156 | return create_symmetric_key_helper() 157 | 158 | def encrypt_symmetric(self, plaintext: str, key: str): 159 | """Encrypt the plaintext `plaintext` with the (base64) 256-bit secret key `key`""" 160 | return encrypt_symmetric_helper(plaintext, key) 161 | 162 | def decrypt_symmetric(self, ciphertext: str, key: str, iv: str, tag: str): 163 | """Decrypt the ciphertext `ciphertext` with the (base64) 256-bit secret key `key`, provided `iv` and `tag`""" 164 | return decrypt_symmetric_helper(ciphertext, key, iv, tag) 165 | -------------------------------------------------------------------------------- /infisical/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | INFISICAL_URL = "https://app.infisical.com" 4 | AUTH_MODE_SERVICE_TOKEN = "service_token" 5 | AUTH_MODE_SERVICE_TOKEN_V3 = "service_token_v3" 6 | 7 | SERVICE_TOKEN_REGEX = re.compile(r"(st\.[a-f0-9-]+\.[a-f0-9]+)\.([a-f0-9]+)") 8 | -------------------------------------------------------------------------------- /infisical/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | class InfisicalTokenError(ValueError): 2 | pass 3 | -------------------------------------------------------------------------------- /infisical/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infisical/infisical-python/28bf675217ae8713b5480974b195e7a62847fe3d/infisical/helpers/__init__.py -------------------------------------------------------------------------------- /infisical/helpers/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timedelta 3 | from typing import TYPE_CHECKING, Union 4 | 5 | from typing_extensions import Literal 6 | 7 | if TYPE_CHECKING: 8 | from infisical import InfisicalClient 9 | 10 | from infisical.logger import logger 11 | from infisical.models.models import SecretBundle 12 | from infisical.services.secret_service import SecretService 13 | 14 | 15 | def get_all_secrets_helper(instance: "InfisicalClient", environment: str, path: str, include_imports: bool, attach_to_os_environ: bool): 16 | try: 17 | if not instance.client_config: 18 | raise Exception("Failed to find client config") 19 | 20 | if not instance.client_config.workspace_config: 21 | instance.client_config.workspace_config = ( 22 | SecretService.populate_client_config( 23 | api_request=instance.api_request, 24 | client_config=instance.client_config, 25 | ) 26 | ) 27 | 28 | secret_bundles = SecretService.get_decrypted_secrets( 29 | api_request=instance.api_request, 30 | workspace_id=instance.client_config.workspace_config.workspace_id, 31 | environment=environment, 32 | path=path, 33 | workspace_key=instance.client_config.workspace_config.workspace_key, 34 | include_imports=include_imports 35 | ) 36 | 37 | for secret_bundle in secret_bundles: 38 | cache_key = f"{secret_bundle.type}-{secret_bundle.secret_name}" 39 | instance.cache[cache_key] = secret_bundle 40 | if attach_to_os_environ: 41 | os.environ[secret_bundle.secret_name] = secret_bundle.secret_value 42 | 43 | return secret_bundles 44 | except Exception as exc: 45 | if instance.debug: 46 | logger.exception(exc) 47 | 48 | return [SecretService.get_fallback_secret(secret_name="")] 49 | 50 | 51 | def get_secret_helper( 52 | instance: "InfisicalClient", 53 | secret_name: str, 54 | type: Literal["shared", "personal"], 55 | environment: str, 56 | path: str, 57 | ): 58 | cache_key = f"{type}-{secret_name}" 59 | cached_secret: Union[SecretBundle, None] = None 60 | try: 61 | if not instance.client_config: 62 | raise Exception("Failed to find client config") 63 | 64 | if not instance.client_config.workspace_config: 65 | instance.client_config.workspace_config = ( 66 | SecretService.populate_client_config( 67 | api_request=instance.api_request, 68 | client_config=instance.client_config, 69 | ) 70 | ) 71 | 72 | cached_secret = instance.cache.get(cache_key) 73 | 74 | if cached_secret: 75 | current_time = datetime.now() 76 | cache_expiry_time = cached_secret.last_fetched_at + timedelta( 77 | seconds=instance.client_config.cache_ttl 78 | ) 79 | 80 | if current_time < cache_expiry_time: 81 | if instance.debug: 82 | print(f"Returning cached secret: {cached_secret.secret_name}") 83 | 84 | return cached_secret 85 | 86 | secret_bundle = SecretService.get_decrypted_secret( 87 | api_request=instance.api_request, 88 | secret_name=secret_name, 89 | workspace_id=instance.client_config.workspace_config.workspace_id, 90 | environment=environment, 91 | workspace_key=instance.client_config.workspace_config.workspace_key, 92 | type=type, 93 | path=path, 94 | ) 95 | 96 | instance.cache[secret_name] = secret_bundle 97 | 98 | return secret_bundle 99 | 100 | except Exception as exc: 101 | if instance.debug: 102 | logger.exception(exc) 103 | 104 | if cached_secret: 105 | if instance.debug: 106 | print(f"Returning cached secret: {cached_secret}") 107 | 108 | return cached_secret 109 | 110 | return SecretService.get_fallback_secret(secret_name=secret_name) 111 | 112 | 113 | def create_secret_helper( 114 | instance: "InfisicalClient", 115 | secret_name: str, 116 | secret_value: str, 117 | type: Literal["shared", "personal"], 118 | environment: str, 119 | path: str, 120 | ): 121 | try: 122 | if not instance.client_config: 123 | raise Exception("Failed to find client config") 124 | 125 | if not instance.client_config.workspace_config: 126 | instance.client_config.workspace_config = ( 127 | SecretService.populate_client_config( 128 | api_request=instance.api_request, 129 | client_config=instance.client_config, 130 | ) 131 | ) 132 | 133 | secret_bundle = SecretService.create_secret( 134 | api_request=instance.api_request, 135 | secret_name=secret_name, 136 | secret_value=secret_value, 137 | workspace_id=instance.client_config.workspace_config.workspace_id, 138 | environment=environment, 139 | workspace_key=instance.client_config.workspace_config.workspace_key, 140 | type=type, 141 | path=path, 142 | ) 143 | 144 | cache_key = f"{type}-{secret_name}" 145 | instance.cache[cache_key] = secret_bundle 146 | 147 | return secret_bundle 148 | except Exception as exc: 149 | if instance.debug: 150 | logger.exception(exc) 151 | 152 | return SecretService.get_fallback_secret(secret_name=secret_name) 153 | 154 | 155 | def update_secret_helper( 156 | instance: "InfisicalClient", 157 | secret_name: str, 158 | secret_value: str, 159 | type: Literal["shared", "personal"], 160 | environment: str, 161 | path: str, 162 | ): 163 | try: 164 | if not instance.client_config: 165 | raise Exception("Failed to find client config") 166 | 167 | if not instance.client_config.workspace_config: 168 | instance.client_config.workspace_config = ( 169 | SecretService.populate_client_config( 170 | api_request=instance.api_request, 171 | client_config=instance.client_config, 172 | ) 173 | ) 174 | 175 | secret_bundle = SecretService.update_secret( 176 | api_request=instance.api_request, 177 | secret_name=secret_name, 178 | secret_value=secret_value, 179 | workspace_id=instance.client_config.workspace_config.workspace_id, 180 | environment=environment, 181 | workspace_key=instance.client_config.workspace_config.workspace_key, 182 | type=type, 183 | path=path, 184 | ) 185 | 186 | cache_key = f"{type}-{secret_name}" 187 | instance.cache[cache_key] = secret_bundle 188 | 189 | return secret_bundle 190 | except Exception as exc: 191 | if instance.debug: 192 | logger.exception(exc) 193 | 194 | return SecretService.get_fallback_secret(secret_name=secret_name) 195 | 196 | 197 | def delete_secret_helper( 198 | instance: "InfisicalClient", 199 | secret_name: str, 200 | type: Literal["shared", "personal"], 201 | environment: str, 202 | path: str, 203 | ): 204 | try: 205 | if not instance.client_config: 206 | raise Exception("Failed to find client config") 207 | 208 | if not instance.client_config.workspace_config: 209 | instance.client_config.workspace_config = ( 210 | SecretService.populate_client_config( 211 | api_request=instance.api_request, 212 | client_config=instance.client_config, 213 | ) 214 | ) 215 | 216 | secret_bundle = SecretService.delete_secret( 217 | api_request=instance.api_request, 218 | secret_name=secret_name, 219 | workspace_id=instance.client_config.workspace_config.workspace_id, 220 | environment=environment, 221 | workspace_key=instance.client_config.workspace_config.workspace_key, 222 | type=type, 223 | path=path, 224 | ) 225 | 226 | cache_key = f"{type}-{secret_name}" 227 | instance.cache[cache_key] = secret_bundle 228 | 229 | return secret_bundle 230 | except Exception as exc: 231 | if instance.debug: 232 | logger.exception(exc) 233 | 234 | return SecretService.get_fallback_secret(secret_name=secret_name) 235 | -------------------------------------------------------------------------------- /infisical/helpers/secrets.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from infisical.models.models import Secret, SecretBundle 4 | 5 | 6 | def transform_secret_to_secret_bundle( 7 | secret: Secret, secret_name: str, secret_value: str 8 | ) -> SecretBundle: 9 | return SecretBundle( 10 | secret_name=secret_name, 11 | secret_value=secret_value, 12 | version=secret.version, 13 | workspace=secret.workspace, 14 | environment=secret.environment, 15 | type=secret.type, 16 | updated_at=secret.updated_at, 17 | created_at=secret.created_at, 18 | is_fallback=False, 19 | last_fetched_at=datetime.now(), 20 | ) 21 | -------------------------------------------------------------------------------- /infisical/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger("infisical") 4 | -------------------------------------------------------------------------------- /infisical/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infisical/infisical-python/28bf675217ae8713b5480974b195e7a62847fe3d/infisical/models/__init__.py -------------------------------------------------------------------------------- /infisical/models/api.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from infisical.models.models import Secret 4 | from pydantic import BaseModel 5 | from typing_extensions import Literal 6 | 7 | 8 | class GetSecretsDTO(BaseModel): 9 | workspace_id: str 10 | environment: str 11 | path: str 12 | include_imports: bool 13 | 14 | 15 | class GetSecretDTO(BaseModel): 16 | secret_name: str 17 | workspace_id: str 18 | environment: str 19 | type: Literal["shared", "personal"] 20 | path: str 21 | 22 | 23 | class CreateSecretDTO(BaseModel): 24 | secret_name: str 25 | workspace_id: str 26 | environment: str 27 | type: Literal["shared", "personal"] 28 | path: str 29 | secret_key_ciphertext: str 30 | secret_key_iv: str 31 | secret_key_tag: str 32 | secret_value_ciphertext: str 33 | secret_value_iv: str 34 | secret_value_tag: str 35 | 36 | 37 | class UpdateSecretDTO(BaseModel): 38 | secret_name: str 39 | workspace_id: str 40 | environment: str 41 | type: Literal["shared", "personal"] 42 | path: str 43 | secret_value_ciphertext: str 44 | secret_value_iv: str 45 | secret_value_tag: str 46 | 47 | 48 | class DeleteSecretDTO(BaseModel): 49 | secret_name: str 50 | workspace_id: str 51 | environment: str 52 | type: Literal["shared", "personal"] 53 | path: str 54 | 55 | 56 | class SecretImport(BaseModel): 57 | secretPath: str 58 | folderId: str 59 | environment: str 60 | secrets: List[Secret] 61 | 62 | class SecretsResponse(BaseModel): 63 | secrets: List[Secret] 64 | imports: Optional[List[SecretImport]] 65 | 66 | 67 | class SecretResponse(BaseModel): 68 | secret: Secret 69 | -------------------------------------------------------------------------------- /infisical/models/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, Field 5 | from typing_extensions import Literal 6 | 7 | 8 | class Secret(BaseModel): 9 | id: str = Field(..., alias="id") 10 | version: int 11 | workspace: str 12 | user: Optional[str] 13 | type: Literal["shared", "personal"] 14 | environment: str 15 | secret_key_ciphertext: str = Field(..., alias="secretKeyCiphertext") 16 | secret_key_iv: str = Field(..., alias="secretKeyIV") 17 | secret_key_tag: str = Field(..., alias="secretKeyTag") 18 | secret_value_ciphertext: str = Field(..., alias="secretValueCiphertext") 19 | secret_value_iv: str = Field(..., alias="secretValueIV") 20 | secret_value_tag: str = Field(..., alias="secretValueTag") 21 | created_at: datetime = Field(..., alias="createdAt") 22 | updated_at: datetime = Field(..., alias="updatedAt") 23 | 24 | 25 | class SecretBundle(BaseModel): 26 | secret_name: str 27 | secret_value: Optional[str] 28 | version: Optional[int] 29 | # workspace: Optional[str] 30 | # environment: Optional[str] 31 | type: Optional[Literal["shared", "personal"]] 32 | created_at: Optional[datetime] = Field(None, alias="createdAt") 33 | updated_at: Optional[datetime] = Field(None, alias="updatedAt") 34 | is_fallback: bool 35 | last_fetched_at: datetime 36 | 37 | 38 | class ServiceTokenData(BaseModel): 39 | id: str = Field(..., alias="id") 40 | name: str 41 | workspace: str 42 | environment: str 43 | user: str 44 | service_account: str 45 | last_used: datetime = Field() 46 | expires_at: datetime = Field(..., alias="expiresAt") 47 | encrypted_key: str = Field(..., alias="encryptedKey") 48 | iv: str 49 | tag: str 50 | created_at: datetime = Field(..., alias="createdAt") 51 | updated_at: datetime = Field(..., alias="updatedAt") 52 | -------------------------------------------------------------------------------- /infisical/models/secret_service.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Union 2 | 3 | from pydantic import BaseModel 4 | from requests import Session 5 | from typing_extensions import Literal 6 | 7 | class WorkspaceConfig(BaseModel): 8 | workspace_id: str 9 | workspace_key: str 10 | 11 | class ServiceTokenCredentials(BaseModel): 12 | service_token_key: str 13 | 14 | class ServiceTokenV3Credentials(BaseModel): 15 | public_key: str 16 | private_key: str 17 | 18 | class ClientConfig(BaseModel): 19 | auth_mode: Literal["service_token", "service_token_v3"] 20 | credentials: Union[ServiceTokenCredentials, ServiceTokenV3Credentials] 21 | workspace_config: Optional[WorkspaceConfig] 22 | cache_ttl: int 23 | -------------------------------------------------------------------------------- /infisical/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infisical/infisical-python/28bf675217ae8713b5480974b195e7a62847fe3d/infisical/services/__init__.py -------------------------------------------------------------------------------- /infisical/services/secret_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import List 4 | 5 | from infisical.api.create_secret import create_secret_req 6 | from infisical.api.delete_secret import delete_secret_req 7 | from infisical.api.get_secret import get_secret_req 8 | from infisical.api.get_secrets import get_secrets_req 9 | from infisical.api.get_service_token_data import get_service_token_data_req 10 | from infisical.api.get_service_token_data_key import get_service_token_data_key_req 11 | from infisical.api.update_secret import update_secret_req 12 | from infisical.helpers.secrets import transform_secret_to_secret_bundle 13 | from infisical.models.api import ( 14 | CreateSecretDTO, 15 | DeleteSecretDTO, 16 | GetSecretDTO, 17 | GetSecretsDTO, 18 | UpdateSecretDTO, 19 | ) 20 | from infisical.models.models import SecretBundle 21 | from infisical.models.secret_service import ClientConfig, WorkspaceConfig 22 | 23 | # from infisical.utils.crypto import decrypt_symmetric, encrypt_symmetric 24 | from infisical.utils.crypto import ( 25 | decrypt_symmetric_128_bit_hex_key_utf8, 26 | encrypt_symmetric_128_bit_hex_key_utf8, 27 | decrypt_asymmetric 28 | ) 29 | from requests import Session 30 | from typing_extensions import Literal 31 | 32 | 33 | class SecretService: 34 | @staticmethod 35 | def populate_client_config( 36 | api_request: Session, client_config: ClientConfig 37 | ) -> WorkspaceConfig: 38 | if client_config.auth_mode == "service_token": 39 | service_token_details = get_service_token_data_req(api_request) 40 | workspace_key = decrypt_symmetric_128_bit_hex_key_utf8( 41 | ciphertext=service_token_details.encrypted_key, 42 | iv=service_token_details.iv, 43 | tag=service_token_details.tag, 44 | key=client_config.credentials.service_token_key, 45 | ) 46 | 47 | return WorkspaceConfig( 48 | workspace_id=service_token_details.workspace, 49 | workspace_key=workspace_key, 50 | ) 51 | 52 | if client_config.auth_mode == "service_token_v3": 53 | service_token_key_details = get_service_token_data_key_req(api_request) 54 | workspace_key = decrypt_asymmetric( 55 | ciphertext=service_token_key_details.key.encrypted_key, 56 | nonce=service_token_key_details.key.nonce, 57 | public_key=service_token_key_details.key.public_key, 58 | private_key=client_config.credentials.private_key 59 | ) 60 | 61 | return WorkspaceConfig( 62 | workspace_id=service_token_key_details.key.workspace, 63 | workspace_key=workspace_key 64 | ) 65 | 66 | @staticmethod 67 | def get_fallback_secret(secret_name: str) -> SecretBundle: 68 | return SecretBundle( 69 | secret_name=secret_name, 70 | secret_value=os.environ[secret_name], 71 | is_fallback=True, 72 | last_fetched_at=datetime.now(), 73 | ) 74 | 75 | @staticmethod 76 | def get_decrypted_secrets( 77 | api_request: Session, 78 | workspace_key: str, 79 | workspace_id: str, 80 | environment: str, 81 | path: str, 82 | include_imports: bool 83 | ) -> List[SecretBundle]: 84 | options = GetSecretsDTO( 85 | workspace_id=workspace_id, environment=environment, path=path, include_imports=include_imports 86 | ) 87 | 88 | encrypted_secrets, secret_imports = get_secrets_req(api_request, options) 89 | 90 | secret_bundles: List[SecretBundle] = [] 91 | 92 | for encrypted_secret in encrypted_secrets: 93 | secret_name = decrypt_symmetric_128_bit_hex_key_utf8( 94 | ciphertext=encrypted_secret.secret_key_ciphertext, 95 | iv=encrypted_secret.secret_key_iv, 96 | tag=encrypted_secret.secret_key_tag, 97 | key=workspace_key, 98 | ) 99 | 100 | secret_value = decrypt_symmetric_128_bit_hex_key_utf8( 101 | ciphertext=encrypted_secret.secret_value_ciphertext, 102 | iv=encrypted_secret.secret_value_iv, 103 | tag=encrypted_secret.secret_value_tag, 104 | key=workspace_key, 105 | ) 106 | 107 | secret_bundles.append( 108 | transform_secret_to_secret_bundle( 109 | secret=encrypted_secret, 110 | secret_name=secret_name, 111 | secret_value=secret_value, 112 | ) 113 | ) 114 | 115 | for secret_import in secret_imports: 116 | for encrypted_secret in secret_import.secrets: 117 | secret_name = decrypt_symmetric_128_bit_hex_key_utf8( 118 | ciphertext=encrypted_secret.secret_key_ciphertext, 119 | iv=encrypted_secret.secret_key_iv, 120 | tag=encrypted_secret.secret_key_tag, 121 | key=workspace_key, 122 | ) 123 | 124 | secret_value = decrypt_symmetric_128_bit_hex_key_utf8( 125 | ciphertext=encrypted_secret.secret_value_ciphertext, 126 | iv=encrypted_secret.secret_value_iv, 127 | tag=encrypted_secret.secret_value_tag, 128 | key=workspace_key, 129 | ) 130 | 131 | secret_bundles.append( 132 | transform_secret_to_secret_bundle( 133 | secret=encrypted_secret, 134 | secret_name=secret_name, 135 | secret_value=secret_value, 136 | ) 137 | ) 138 | 139 | return secret_bundles 140 | 141 | @staticmethod 142 | def get_decrypted_secret( 143 | api_request: Session, 144 | secret_name: str, 145 | workspace_id: str, 146 | environment: str, 147 | workspace_key: str, 148 | type: Literal["shared", "personal"], 149 | path: str, 150 | ): 151 | options = GetSecretDTO( 152 | secret_name=secret_name, 153 | workspace_id=workspace_id, 154 | environment=environment, 155 | type=type, 156 | path=path, 157 | ) 158 | 159 | encrypted_secret = get_secret_req( 160 | api_request, 161 | options, 162 | ) 163 | 164 | secret_value = decrypt_symmetric_128_bit_hex_key_utf8( 165 | ciphertext=encrypted_secret.secret.secret_value_ciphertext, 166 | iv=encrypted_secret.secret.secret_value_iv, 167 | tag=encrypted_secret.secret.secret_value_tag, 168 | key=workspace_key, 169 | ) 170 | 171 | return transform_secret_to_secret_bundle( 172 | secret=encrypted_secret.secret, 173 | secret_name=secret_name, 174 | secret_value=secret_value, 175 | ) 176 | 177 | @staticmethod 178 | def create_secret( 179 | api_request: Session, 180 | workspace_key: str, 181 | workspace_id: str, 182 | environment: str, 183 | type: Literal["shared", "personal"], 184 | secret_name: str, 185 | secret_value: str, 186 | path: str, 187 | ): 188 | ( 189 | secret_key_ciphertext, 190 | secret_key_iv, 191 | secret_key_tag, 192 | ) = encrypt_symmetric_128_bit_hex_key_utf8( 193 | plaintext=secret_name, key=workspace_key 194 | ) 195 | 196 | ( 197 | secret_value_ciphertext, 198 | secret_value_iv, 199 | secret_value_tag, 200 | ) = encrypt_symmetric_128_bit_hex_key_utf8( 201 | plaintext=secret_value, key=workspace_key 202 | ) 203 | 204 | options = CreateSecretDTO( 205 | secret_name=secret_name, 206 | workspace_id=workspace_id, 207 | environment=environment, 208 | type=type, 209 | path=path, 210 | secret_key_ciphertext=secret_key_ciphertext, 211 | secret_key_iv=secret_key_iv, 212 | secret_key_tag=secret_key_tag, 213 | secret_value_ciphertext=secret_value_ciphertext, 214 | secret_value_iv=secret_value_iv, 215 | secret_value_tag=secret_value_tag, 216 | ) 217 | 218 | encrypted_secret = create_secret_req(api_request, options) 219 | 220 | return transform_secret_to_secret_bundle( 221 | secret=encrypted_secret.secret, 222 | secret_name=secret_name, 223 | secret_value=secret_value, 224 | ) 225 | 226 | @staticmethod 227 | def update_secret( 228 | api_request: Session, 229 | workspace_key: str, 230 | workspace_id: str, 231 | environment: str, 232 | type: Literal["shared", "personal"], 233 | secret_name: str, 234 | secret_value: str, 235 | path: str, 236 | ): 237 | ( 238 | secret_value_ciphertext, 239 | secret_value_iv, 240 | secret_value_tag, 241 | ) = encrypt_symmetric_128_bit_hex_key_utf8( 242 | plaintext=secret_value, key=workspace_key 243 | ) 244 | 245 | options = UpdateSecretDTO( 246 | secret_name=secret_name, 247 | workspace_id=workspace_id, 248 | environment=environment, 249 | type=type, 250 | secret_value_ciphertext=secret_value_ciphertext, 251 | secret_value_iv=secret_value_iv, 252 | secret_value_tag=secret_value_tag, 253 | path=path, 254 | ) 255 | 256 | encrypted_secret = update_secret_req(api_request, options) 257 | return transform_secret_to_secret_bundle( 258 | secret=encrypted_secret.secret, 259 | secret_name=secret_name, 260 | secret_value=secret_value, 261 | ) 262 | 263 | @staticmethod 264 | def delete_secret( 265 | api_request: Session, 266 | workspace_key: str, 267 | workspace_id: str, 268 | environment: str, 269 | type: Literal["shared", "personal"], 270 | path: str, 271 | secret_name: str, 272 | ): 273 | options = DeleteSecretDTO( 274 | secret_name=secret_name, 275 | workspace_id=workspace_id, 276 | environment=environment, 277 | type=type, 278 | path=path, 279 | ) 280 | 281 | encrypted_secret = delete_secret_req(api_request, options) 282 | 283 | secret_value = decrypt_symmetric_128_bit_hex_key_utf8( 284 | ciphertext=encrypted_secret.secret.secret_value_ciphertext, 285 | iv=encrypted_secret.secret.secret_value_iv, 286 | tag=encrypted_secret.secret.secret_value_tag, 287 | key=workspace_key, 288 | ) 289 | 290 | return transform_secret_to_secret_bundle( 291 | secret=encrypted_secret.secret, 292 | secret_name=secret_name, 293 | secret_value=secret_value, 294 | ) 295 | -------------------------------------------------------------------------------- /infisical/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infisical/infisical-python/28bf675217ae8713b5480974b195e7a62847fe3d/infisical/utils/__init__.py -------------------------------------------------------------------------------- /infisical/utils/crypto.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | from typing import Tuple, Union 3 | 4 | from Cryptodome.Cipher import AES 5 | from Cryptodome.Random import get_random_bytes 6 | from nacl import public, utils 7 | 8 | Base64String = str 9 | Buffer = Union[bytes, bytearray, memoryview] 10 | import binascii 11 | 12 | 13 | def encrypt_asymmetric( 14 | plaintext: Union[Buffer, str], 15 | public_key: Union[Buffer, Base64String, public.PublicKey], 16 | private_key: Union[Buffer, Base64String, public.PrivateKey], 17 | ) -> Tuple[Base64String, Base64String]: 18 | """Performs asymmetric encryption of the ``plaintext`` with x25519-xsalsa20-poly1305 19 | algorithm with the given parameters. Each of those params should be either the raw value in bytes 20 | or a base64 string. 21 | 22 | :param plaintext: The text to encrypt 23 | :param public_key: The public key 24 | :param private_key: The private key 25 | :raises ValueError: If ``plaintext``, ``public_key`` or ``private_key`` are empty 26 | :return: A tuple containing the ciphered text and the random nonce used for encryption 27 | """ 28 | if (not isinstance(public_key, public.PublicKey) and len(public_key) == 0) or ( 29 | not isinstance(private_key, public.PrivateKey) and len(private_key) == 0 30 | ): 31 | raise ValueError("Public key and private key cannot be empty!") 32 | 33 | m_plaintext = ( 34 | str.encode(plaintext, "utf-8") if isinstance(plaintext, str) else plaintext 35 | ) 36 | m_public_key = ( 37 | b64decode(public_key) if isinstance(public_key, Base64String) else public_key 38 | ) 39 | m_public_key = ( 40 | public.PublicKey(m_public_key) 41 | if isinstance(m_public_key, (bytes, bytearray, memoryview)) 42 | else m_public_key 43 | ) 44 | m_private_key = ( 45 | b64decode(private_key) if isinstance(private_key, Base64String) else private_key 46 | ) 47 | m_private_key = ( 48 | public.PrivateKey(m_private_key) 49 | if isinstance(m_private_key, (bytes, bytearray, memoryview)) 50 | else m_private_key 51 | ) 52 | 53 | nonce = utils.random(24) 54 | box = public.Box(m_private_key, m_public_key) 55 | ciphertext = box.encrypt(m_plaintext, nonce).ciphertext 56 | 57 | return (b64encode(ciphertext).decode("utf-8"), b64encode(nonce).decode("utf-8")) 58 | 59 | 60 | def decrypt_asymmetric( 61 | ciphertext: Union[Buffer, Base64String], 62 | nonce: Union[Buffer, Base64String], 63 | public_key: Union[Buffer, Base64String, public.PublicKey], 64 | private_key: Union[Buffer, Base64String, public.PrivateKey], 65 | ) -> str: 66 | """Performs asymmetric decryption of the ``ciphertext`` with x25519-xsalsa20-poly1305 67 | algorithm with the given parameters. Each of those params should be either the raw value in bytes 68 | or a base64 string. 69 | 70 | :param ciphertext: The ciphered text to decrypt 71 | :param nonce: The nonce used for encryption 72 | :param public_key: The public key 73 | :param private_key: The private key 74 | :raises ValueError: If ``ciphertext``, ``nonce``, ``public_key`` or ``private_key`` are empty 75 | :return: The deciphered text 76 | """ 77 | if ( 78 | len(ciphertext) == 0 79 | or len(nonce) == 0 80 | or (not isinstance(public_key, public.PublicKey) and len(public_key) == 0) 81 | or (not isinstance(private_key, public.PrivateKey) and len(private_key) == 0) 82 | ): 83 | raise ValueError( 84 | "Public key, private key, ciphertext and nonce cannot be empty!" 85 | ) 86 | 87 | m_ciphertext = ( 88 | b64decode(ciphertext) if isinstance(ciphertext, Base64String) else ciphertext 89 | ) 90 | m_nonce = b64decode(nonce) if isinstance(nonce, Base64String) else nonce 91 | m_public_key = ( 92 | b64decode(public_key) if isinstance(public_key, Base64String) else public_key 93 | ) 94 | m_public_key = ( 95 | public.PublicKey(m_public_key) 96 | if isinstance(m_public_key, (bytes, bytearray, memoryview)) 97 | else m_public_key 98 | ) 99 | m_private_key = ( 100 | b64decode(private_key) if isinstance(private_key, Base64String) else private_key 101 | ) 102 | m_private_key = ( 103 | public.PrivateKey(m_private_key) 104 | if isinstance(m_private_key, (bytes, bytearray, memoryview)) 105 | else m_private_key 106 | ) 107 | 108 | box = public.Box(m_private_key, m_public_key) 109 | plaintext = box.decrypt(m_ciphertext, m_nonce) 110 | 111 | return plaintext.decode("utf-8") 112 | 113 | 114 | def create_symmetric_key_helper(): 115 | return b64encode(get_random_bytes(32)).decode("utf-8") 116 | 117 | 118 | def encrypt_symmetric_helper(plaintext: str, key: str): 119 | IV_BYTES_SIZE = 12 120 | iv = get_random_bytes(12) 121 | 122 | cipher = AES.new(b64decode(key), AES.MODE_GCM, nonce=iv) 123 | 124 | ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8")) 125 | 126 | return ( 127 | b64encode(ciphertext).decode("utf-8"), 128 | b64encode(iv).decode("utf-8"), 129 | b64encode(tag).decode("utf-8"), 130 | ) 131 | 132 | 133 | def decrypt_symmetric_helper(ciphertext: str, key: str, iv: str, tag: str): 134 | cipher = AES.new(b64decode(key), AES.MODE_GCM, nonce=b64decode(iv)) 135 | plaintext = cipher.decrypt_and_verify(b64decode(ciphertext), b64decode(tag)) 136 | 137 | return plaintext.decode("utf-8") 138 | 139 | 140 | def encrypt_symmetric_128_bit_hex_key_utf8( 141 | plaintext: str, key: str 142 | ) -> Tuple[Base64String, Base64String, Base64String]: 143 | """Encrypts the ``plaintext`` with aes-256-gcm using the given ``key``. 144 | The key should be either the raw value in bytes or a base64 string. 145 | 146 | :param plaintext: text to encrypt 147 | :param key: UTF-8, 128-bit AES key used for encryption 148 | :raises ValueError: If either ``plaintext`` or ``key`` is empty 149 | :return: Ciphered text 150 | """ 151 | if len(key) == 0: 152 | raise ValueError("The given key is empty!") 153 | 154 | BLOCK_SIZE_BYTES = 16 155 | 156 | iv = get_random_bytes(BLOCK_SIZE_BYTES) 157 | cipher = AES.new(bytes(key, "utf-8"), AES.MODE_GCM, nonce=iv) 158 | 159 | ciphertext, tag = cipher.encrypt_and_digest(str.encode(plaintext, "utf-8")) 160 | 161 | return ( 162 | b64encode(ciphertext).decode("utf-8"), 163 | b64encode(iv).decode("utf-8"), 164 | b64encode(tag).decode("utf-8"), 165 | ) 166 | 167 | 168 | def decrypt_symmetric_128_bit_hex_key_utf8( 169 | key: str, ciphertext: str, tag: str, iv: str 170 | ) -> str: 171 | """Decrypts the ``ciphertext`` with aes-256-gcm using ``iv``, ``tag`` 172 | and ``key``. 173 | 174 | :param key: UTF-8, 128-bit hex AES key 175 | :param ciphertext: base64 ciphered text to decrypt 176 | :param tag: base64 tag/mac used for verification 177 | :param iv: base64 nonce 178 | :raises ValueError: If ``ciphertext``, ``iv``, ``tag`` or ``key`` are empty or tag/mac does not match 179 | :return: Deciphered text 180 | """ 181 | if len(tag) == 0 or len(iv) == 0 or len(key) == 0: 182 | raise ValueError("One of the given parameter is empty!") 183 | 184 | try: 185 | key = bytes(key, "utf-8") 186 | iv = b64decode(iv) 187 | tag = b64decode(tag) 188 | ciphertext = b64decode(ciphertext) 189 | 190 | cipher = AES.new(key, AES.MODE_GCM, nonce=iv) 191 | plaintext = cipher.decrypt_and_verify(ciphertext, tag) 192 | 193 | return plaintext.decode("utf-8") 194 | except ValueError: 195 | raise ValueError("Incorrect decryption or MAC check failed") 196 | -------------------------------------------------------------------------------- /infisical/utils/http.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Union 2 | from urllib.parse import urljoin 3 | 4 | import requests 5 | from requests.adapters import HTTPAdapter 6 | from urllib3.util import Retry 7 | 8 | DEFAULT_TIMEOUT = 40 # seconds 9 | 10 | 11 | class BaseUrlSession(requests.Session): 12 | base_url = "" 13 | 14 | def __init__(self, base_url: Optional[str] = None) -> None: 15 | if base_url: 16 | self.base_url = base_url 17 | super().__init__() 18 | 19 | def request( 20 | self, 21 | method: Union[str, bytes], 22 | url: Any, 23 | *args: Any, 24 | **kwargs: Any, 25 | ) -> requests.Response: 26 | url = self.create_url(url) 27 | return super().request(method, url, *args, **kwargs) 28 | 29 | def prepare_request(self, request: requests.Request) -> requests.PreparedRequest: 30 | request.url = self.create_url(request.url) 31 | return super().prepare_request(request) 32 | 33 | def create_url(self, url: str) -> str: 34 | return urljoin(self.base_url, url) 35 | 36 | 37 | class TimeoutHTTPAdapter(HTTPAdapter): 38 | def __init__(self, *args: Any, **kwargs: Any) -> None: 39 | self.timeout = DEFAULT_TIMEOUT 40 | if "timeout" in kwargs: 41 | self.timeout = kwargs["timeout"] 42 | del kwargs["timeout"] 43 | super().__init__(*args, **kwargs) 44 | 45 | def send( 46 | self, request: requests.PreparedRequest, *args: Any, **kwargs: Any 47 | ) -> requests.Response: 48 | timeout = kwargs.get("timeout") 49 | if timeout is None: 50 | kwargs["timeout"] = self.timeout 51 | return super().send(request, *args, **kwargs) 52 | 53 | 54 | def get_http_client( 55 | base_url: Optional[str], 56 | retries: int = 3, 57 | backoff_factor: int = 1, 58 | timeout: int = DEFAULT_TIMEOUT, 59 | ) -> BaseUrlSession: 60 | """Returns a pre-configured :class:`requests.Session` with a optional ``base_url`` 61 | and some sane options for timeout and retry handling. 62 | 63 | :param base_url: (optional) A base url used for each request made with this client 64 | :param retries: The number of retries to do if request fail, defaults to 3 65 | :param backoff_factor: A backoff factor to apply between attempts after the second try, defaults to 1 66 | :param timeout: The timeout in seconds, defaults to 40 67 | :return: A ready-to-use instance of :class:`requests.Session` 68 | """ 69 | retry_strategy = Retry( 70 | total=retries, 71 | backoff_factor=backoff_factor, 72 | status_forcelist=[429, 500, 502, 503, 504], 73 | ) 74 | adapter = TimeoutHTTPAdapter(max_retries=retry_strategy, timeout=timeout) 75 | 76 | http = BaseUrlSession(base_url=base_url) 77 | http.mount("https://", adapter) 78 | http.mount("http://", adapter) 79 | 80 | return http 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "infisical" 7 | description = 'Official Infisical SDK for Python' 8 | readme = "README.md" 9 | requires-python = ">=3.7" 10 | license = "MIT" 11 | authors = [] 12 | maintainers = [ 13 | { name = "Yohann MARTIN", email = "contact@codexus.fr" }, 14 | { name = "Tony Dang", email = "tony@infisical.com"} 15 | ] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Operating System :: OS Independent", 19 | "Topic :: Software Development :: Libraries :: Application Frameworks", 20 | "Topic :: Software Development :: Libraries :: Python Modules", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Software Development", 23 | "Typing :: Typed", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: Implementation :: CPython", 33 | "Programming Language :: Python :: Implementation :: PyPy", 34 | ] 35 | dependencies = [ 36 | "requests ==2.31.0", 37 | "pydantic >=1.6.2,!=1.7,!=1.7.1,!=1.7.2,!=1.7.3,!=1.8,!=1.8.1,<2.0.0", 38 | "pycryptodomex >=3.17,<4.0.0", 39 | "pynacl >=1.5.0,<2.0.0" 40 | ] 41 | dynamic = ["version"] 42 | 43 | [project.urls] 44 | Documentation = "https://github.com/Infisical/infisical-python#readme" 45 | Issues = "https://github.com/Infisical/infisical-python/issues" 46 | Source = "https://github.com/Infisical/infisical-python" 47 | 48 | [project.optional-dependencies] 49 | test = [ 50 | "pytest >=7.1.3,<8.0.0", 51 | "coverage[toml] >= 6.5.0,< 8.0", 52 | "responses ==0.23.1" 53 | ] 54 | dev = [ 55 | "mypy ==1.1.1", 56 | "ruff ==0.0.261", 57 | "black ==23.3.0", 58 | "isort >=5.0.6,<6.0.0", 59 | "devtools[pygments] ==0.11.0", 60 | 61 | "types-requests ==2.28.11.17" 62 | ] 63 | 64 | [tool.hatch.version] 65 | path = "infisical/__version__.py" 66 | 67 | [tool.hatch.build.targets.sdist] 68 | exclude = [ 69 | "/.github", 70 | "/.vscode", 71 | ] 72 | 73 | [tool.isort] 74 | profile = "black" 75 | known_third_party = ["infisical", "pydantic", "Cryptodome", "nacl", "responses"] 76 | 77 | [tool.mypy] 78 | strict = true 79 | 80 | [tool.coverage.run] 81 | parallel = true 82 | source = [ 83 | "tests", 84 | "infisical" 85 | ] 86 | 87 | [tool.coverage.report] 88 | exclude_lines = [ 89 | "no cov", 90 | "if __name__ == .__main__.:", 91 | "if TYPE_CHECKING:", 92 | ] 93 | 94 | [tool.ruff] 95 | target-version = "py37" 96 | select = [ 97 | "E", # pycodestyle errors 98 | "W", # pycodestyle warnings 99 | "F", # pyflakes 100 | # "I", # isort 101 | "C", # flake8-comprehensions 102 | "B", # flake8-bugbear 103 | ] 104 | ignore = [ 105 | "E501", # line too long, handled by black 106 | "B008", # do not perform function calls in argument defaults 107 | "C901", # too complex 108 | ] 109 | 110 | [tool.ruff.per-file-ignores] 111 | "__init__.py" = ["F401"] 112 | 113 | [tool.ruff.isort] 114 | known-third-party = ["infisical"] 115 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | ruff check infisical tests --fix 5 | black infisical tests 6 | isort infisical tests 7 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | mypy infisical 7 | ruff check infisical tests 8 | black infisical tests --check 9 | isort infisical tests --check-only 10 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | coverage run -m pytest tests ${@} 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Yohann MARTIN 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/test_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Infisical/infisical-python/28bf675217ae8713b5480974b195e7a62847fe3d/tests/test_client/__init__.py -------------------------------------------------------------------------------- /tests/test_client/test_infisical_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from infisical import InfisicalClient 5 | 6 | 7 | @pytest.fixture(scope="module") 8 | def client(): 9 | infisical_client = InfisicalClient( 10 | token=os.environ.get("INFISICAL_TOKEN"), token_json=os.environ.get("INFISICAL_TOKEN_JSON"), site_url=os.environ.get("SITE_URL"), debug=True 11 | ) 12 | 13 | infisical_client.create_secret("KEY_ONE", "KEY_ONE_VAL") 14 | infisical_client.create_secret("KEY_ONE", "KEY_ONE_VAL_PERSONAL", type="personal") 15 | infisical_client.create_secret("KEY_TWO", "KEY_TWO_VAL") 16 | 17 | # infisical_client.create_secret("TESTOO", "sssss", path="/foo", environment="dev") 18 | 19 | yield infisical_client 20 | 21 | infisical_client.delete_secret("KEY_ONE") 22 | infisical_client.delete_secret("KEY_TWO") 23 | infisical_client.delete_secret("KEY_THREE") 24 | 25 | def test_get_overriden_personal_secret(client: InfisicalClient): 26 | secret = client.get_secret("KEY_ONE") 27 | assert secret.secret_name == "KEY_ONE" 28 | assert secret.secret_value == "KEY_ONE_VAL_PERSONAL" 29 | assert secret.type == "personal" 30 | 31 | 32 | def test_get_shared_secret_specified(client: InfisicalClient): 33 | secret = client.get_secret("KEY_ONE", type="shared") 34 | assert secret.secret_name == "KEY_ONE" 35 | assert secret.secret_value == "KEY_ONE_VAL" 36 | assert secret.type == "shared" 37 | 38 | 39 | def test_get_shared_secret(client: InfisicalClient): 40 | secret = client.get_secret("KEY_TWO") 41 | assert secret.secret_name == "KEY_TWO" 42 | assert secret.secret_value == "KEY_TWO_VAL" 43 | assert secret.type == "shared" 44 | 45 | 46 | def test_create_shared_secret(client: InfisicalClient): 47 | secret = client.create_secret("KEY_THREE", "KEY_THREE_VAL") 48 | assert secret.secret_name == "KEY_THREE" 49 | assert secret.secret_value == "KEY_THREE_VAL" 50 | assert secret.type == "shared" 51 | 52 | 53 | def test_create_personal_secret(client: InfisicalClient): 54 | client.create_secret("KEY_FOUR", "KEY_FOUR_VAL") 55 | personal_secret = client.create_secret( 56 | "KEY_FOUR", "KEY_FOUR_VAL_PERSONAL", type="personal" 57 | ) 58 | 59 | assert personal_secret.secret_name == "KEY_FOUR" 60 | assert personal_secret.secret_value == "KEY_FOUR_VAL_PERSONAL" 61 | assert personal_secret.type == "personal" 62 | 63 | 64 | def test_update_shared_secret(client: InfisicalClient): 65 | secret = client.update_secret("KEY_THREE", "FOO") 66 | 67 | assert secret.secret_name == "KEY_THREE" 68 | assert secret.secret_value == "FOO" 69 | assert secret.type == "shared" 70 | 71 | 72 | def test_update_personal_secret(client: InfisicalClient): 73 | secret = client.update_secret("KEY_FOUR", "BAR", type="personal") 74 | assert secret.secret_name == "KEY_FOUR" 75 | assert secret.secret_value == "BAR" 76 | assert secret.type == "personal" 77 | 78 | 79 | def test_delete_personal_secret(client: InfisicalClient): 80 | secret = client.delete_secret("KEY_FOUR", type="personal") 81 | assert secret.secret_name == "KEY_FOUR" 82 | assert secret.secret_value == "BAR" 83 | assert secret.type == "personal" 84 | 85 | 86 | def test_delete_shared_secret(client: InfisicalClient): 87 | secret = client.delete_secret("KEY_FOUR") 88 | assert secret.secret_name == "KEY_FOUR" 89 | assert secret.secret_value == "KEY_FOUR_VAL" 90 | assert secret.type == "shared" 91 | 92 | 93 | def test_encrypt_decrypt_symmetric(client: InfisicalClient): 94 | plaintext = "The quick brown fox jumps over the lazy dog" 95 | key = client.create_symmetric_key() 96 | 97 | ciphertext, iv, tag = client.encrypt_symmetric(plaintext, key) 98 | cleartext = client.decrypt_symmetric(ciphertext, key, iv, tag) 99 | assert plaintext == cleartext 100 | -------------------------------------------------------------------------------- /tests/test_infisical.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | load_dotenv() 4 | --------------------------------------------------------------------------------