Repository: eth-educators/ethstaker-deposit-cli

Issue:
Sensitive files are incorrectly assigned permissions and ownership [TOB-ETHSTAKER-3]
Reported by Trail of Bits from their *EthStaker Deposit CLI Security Assessment* DRAFT report presented on October 8 2024.

## Description
In various locations in the EthStaker codebase, a file is created, written with sensitive data, and then assigned permissions. There are two problems with the current approach: it allows an attacker to read the file in the time after it has been written but before the permissions have been assigned, and it allows an attacker to create a file with himself as the owner before EthStaker writes to the path.

```python
with open (filefolder, 'w') as f:
    f.write(self .as_json())
if os.name == 'posix' :
    os.chmod(filefolder, int( '440' , 8)) # Read for owner & group
```
Here are all the instances of this issue:
- ethstaker_deposit/utils/deposit.py:7-10
- ethstaker_deposit/credentials.py:367-370
- ethstaker_deposit/utils/exit_transaction.py:58-61
- ethstaker_deposit/bls_to_execution_change_keystore.py:67-70
- ethstaker_deposit/key_handling/keystore.py:99-102

## Exploit Scenario 1
A user uses EthStaker to generate and save a keystore. A malicious user on an unprivileged account reads the keystore file after it is written but before its permissions are set, and uses the keystore information to steal the eth.

## Exploit Scenario 2
A user uses EthStaker to generate and save a keystore. Before the keystore is written, a malicious user on an unprivileged account had already created a file with that path owned by his own account, with permissions that allow EthStaker to write to the file. EthStaker writes to the file, and its data is now accessible to the malicious user.

In order to prevent the os.chmod call from causing an exception, the attacker would have to read the file and then call chown on it after the file is written to by EthStaker but before the os.chmod call, which may be difficult. Alternatively, this attack can be carried out on a Windows machine, where the os.chmod call does not take place, but due to Windows’ permissioning system this may be less viable.

## Recommendations
Short term, change all the instances of this issue so that they use the os.open function, with the O_EXCL flag set, and a mode parameter of 440.

Long term, centralize file creation behavior by creating a utility function which creates and opens a file using the os.open function. Also, consider using 400 instead of 440 permissions.

Discussion:

Solution patch:
diff --git a/.github/release_template.md b/.github/release_template.md
new file mode 100644
index 00000000..cb7e2675
--- /dev/null
+++ b/.github/release_template.md
@@ -0,0 +1,43 @@
+[comment]: <> (This is a comment, it will not be included in the final release notes.)
+[comment]: <> (This template will be used to automatically generate release notes with the ci-build workflow.)
+[comment]: <> (The following values will be automatically replaced with generated content from the workflow.)
+[comment]: <> (`[GENERATED-RELEASE-NOTES]`: Replaced with the GitHub generated release notes.)
+[comment]: <> (`[WORKFLOW-URL]`: Replaced with the link to the workflow that generated the build.)
+[comment]: <> (`[BINARIES-TABLE]`: Replaced with the a markdown formatted table with a link to each binary download.)
+[comment]: <> (`[DOCKER-TABLE]`: Replaced with the a markdown formatted table with a link to the docker image.)
+
+# Summary
+
+`[Add a small summary here]`
+
+# Known Issues
+
+`[Remove this section if there is no known issue]`
+
+# All changes
+
+`[GENERATED-RELEASE-NOTES]`
+
+# Building process
+
+Release assets were built using Github Actions and [this workflow run](`[WORKFLOW-RUN-URL]`). You can establish the provenance of this build using [our artifact attestations](https://github.com/eth-educators/ethstaker-deposit-cli/attestations).
+
+With [the GitHub CLI](https://cli.github.com/) installed, a simple way to verify these assets is to run this command while replacing `[filename]` with the path to the downloaded asset:
+
+```console
+gh attestation verify [filename] --repo eth-educators/ethstaker-deposit-cli
+```
+
+This step requires you to be online. If you want to perform this offline, follow [these instructions from GitHub](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/verifying-attestations-offline).
+
+# Binaries
+
+`[BINARIES-TABLE]`
+
+# Docker image
+
+`[DOCKER-TABLE]`
+
+## License
+
+By downloading and using this software, you agree to the [license](LICENSE).
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5f586fdc..97c732d8 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,12 +1,18 @@
 name: ci-build
-run-name: ${{ github.actor }} is running ci-build
+run-name: ${{ github.actor }} is building binaries, building the docker image and drafting a release
 on:
   workflow_dispatch:
   push:
     tags:
       - v*
+
+# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
+env:
+  REGISTRY: ghcr.io
+  IMAGE_NAME: ${{ github.repository }}
+
 jobs:
-  ci-build:
+  build-binaries:
     runs-on: ${{ matrix.os }}
     permissions:
       id-token: write
@@ -26,84 +32,69 @@ jobs:
           cache: 'pip'
       - name: Setup variables (Linux & macOS)
         if: ${{ startsWith(matrix.os, 'ubuntu-') || startsWith(matrix.os, 'macos-') }}
+        env:
+          MATRIX_OS: '${{ matrix.os }}'
         run: |
           echo "PYTHONHASHSEED=42" >> "$GITHUB_ENV"
-          export SHORT_SHA=$(echo ${{ github.sha }} | cut -c -7)
+          SHORT_SHA=$(echo ${{ github.sha }} | cut -c -7)
           echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV"
+          if [[ $MATRIX_OS == ubuntu-* ]] ;
+          then
+            BUILD_SYSTEM=linux
+            BUILD_CONFIGS_PATH=./build_configs/linux
+          fi
+          if [[ $MATRIX_OS == macos-* ]] ;
+          then
+            BUILD_SYSTEM=darwin
+            BUILD_CONFIGS_PATH=./build_configs/macos
+            brew install coreutils
+          fi
+          BUILD_ARCHITECTURE=amd64
+          if [[ $MATRIX_OS == *arm* ]] || [[ $MATRIX_OS == macos-latest ]] ;
+          then
+            BUILD_ARCHITECTURE=arm64
+          fi
+          BUILD_FILE_NAME=ethstaker_deposit-cli-${SHORT_SHA}-${BUILD_SYSTEM}-${BUILD_ARCHITECTURE}
+          mkdir "${BUILD_FILE_NAME}"
+          echo "BUILD_FILE_NAME=${BUILD_FILE_NAME}" >> "$GITHUB_ENV"
+          echo "BUILD_CONFIGS_PATH=${BUILD_CONFIGS_PATH}" >> "$GITHUB_ENV"
       - name: Setup variables (Windows)
         if: ${{ startsWith(matrix.os, 'windows-') }}
+        env:
+          MATRIX_OS: '${{ matrix.os }}'
         run: |
           echo "PYTHONHASHSEED=42" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
           $env:SHORT_SHA = "${{ github.sha }}".Substring(0, 7)
           echo ("SHORT_SHA=" + $env:SHORT_SHA) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
-      - name: Install Linux build dependencies
-        if: ${{ startsWith(matrix.os, 'ubuntu-') }}
-        run: |
-          python -m pip install --upgrade pip
-          pip install -r build_configs/linux/requirements.txt
-      - name: Install macOS build dependencies
-        if: ${{ startsWith(matrix.os, 'macos-') }}
-        run: |
-          python -m pip install --upgrade pip
-          pip install -r build_configs/linux/requirements.txt
-      - name: Install Windows build dependencies
-        if: ${{ startsWith(matrix.os, 'windows-') }}
-        run: |
-          python -m pip install --upgrade pip
-          pip install -r build_configs/windows/requirements.txt
-      - name: Build with build.spec (Linux amd64)
-        if: ${{ startsWith(matrix.os, 'ubuntu-') && !contains(matrix.os, 'arm') }}
-        run: |
-          export BUILD_FILE_NAME=ethstaker_deposit-cli-${SHORT_SHA}-linux-amd64
-          echo "BUILD_FILE_NAME=${BUILD_FILE_NAME}" >> "$GITHUB_ENV"
-          mkdir ${BUILD_FILE_NAME}
-          pyinstaller --distpath ./${BUILD_FILE_NAME} ./build_configs/linux/build.spec
-      - name: Build with build.spec (Linux arm64)
-        if: ${{ startsWith(matrix.os, 'ubuntu-') && contains(matrix.os, 'arm') }}
-        run: |
-          export BUILD_FILE_NAME=ethstaker_deposit-cli-${SHORT_SHA}-linux-arm64
-          echo "BUILD_FILE_NAME=${BUILD_FILE_NAME}" >> "$GITHUB_ENV"
-          mkdir ${BUILD_FILE_NAME}
-          pyinstaller --distpath ./${BUILD_FILE_NAME} ./build_configs/linux/build.spec
-      - name: Build with build.spec (macOS amd64)
-        if: ${{ matrix.os == 'macos-13' }}
-        run: |
-          export BUILD_FILE_NAME=ethstaker_deposit-cli-${SHORT_SHA}-darwin-amd64
-          echo "BUILD_FILE_NAME=${BUILD_FILE_NAME}" >> "$GITHUB_ENV"
-          mkdir ${BUILD_FILE_NAME}
-          pyinstaller --distpath ./${BUILD_FILE_NAME} ./build_configs/macos/build.spec
-      - name: Build with build.spec (macOS arm64)
-        if: ${{ matrix.os == 'macos-latest' }}
-        run: |
-          export BUILD_FILE_NAME=ethstaker_deposit-cli-${SHORT_SHA}-darwin-arm64
-          echo "BUILD_FILE_NAME=${BUILD_FILE_NAME}" >> "$GITHUB_ENV"
-          mkdir ${BUILD_FILE_NAME}
-          pyinstaller --distpath ./${BUILD_FILE_NAME} ./build_configs/macos/build.spec
-      - name: Build with build.spec (Windows amd64)
-        if: ${{ startsWith(matrix.os, 'windows-') }}
-        run: |
-          $env:BUILD_FILE_NAME = ("ethstaker_deposit-cli-" + $env:SHORT_SHA + "-windows-amd64")
+          if ($env:MATRIX_OS.Contains("arm")) {
+            $env:BUILD_ARCHITECTURE = "arm64"
+          }
+          else {
+            $env:BUILD_ARCHITECTURE = "amd64"
+          }
+          $env:BUILD_FILE_NAME = ("ethstaker_deposit-cli-" + $env:SHORT_SHA + "-windows-" + $env:BUILD_ARCHITECTURE)
           echo ("BUILD_FILE_NAME=" + $env:BUILD_FILE_NAME) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
           mkdir $env:BUILD_FILE_NAME
           $env:BUILD_FILE_NAME_PATH = (".\" + $env:BUILD_FILE_NAME)
           echo ("BUILD_FILE_NAME_PATH=" + $env:BUILD_FILE_NAME_PATH) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
-          pyinstaller --distpath $env:BUILD_FILE_NAME .\build_configs\windows\build.spec
-      - name: Install coreutils (macOS)
-        if: ${{ startsWith(matrix.os, 'macos-') }}
-        run: |
-          brew install coreutils
-      - name: Create archive and checksum (Linux & macOS)
+      - name: Build on Linux & macOS
         if: ${{ startsWith(matrix.os, 'ubuntu-') || startsWith(matrix.os, 'macos-') }}
         run: |
-          export ARCHIVE_FILE_NAME=${BUILD_FILE_NAME}.tar.gz
+          python -m pip install --upgrade pip
+          pip install -r "${BUILD_CONFIGS_PATH}"/requirements.txt
+          pyinstaller --distpath ./"${BUILD_FILE_NAME}" "${BUILD_CONFIGS_PATH}"/build.spec
+          export ARCHIVE_FILE_NAME="${BUILD_FILE_NAME}".tar.gz
           echo "ARCHIVE_FILE_NAME=${ARCHIVE_FILE_NAME}" >> "$GITHUB_ENV"
-          tar -zcvf ${ARCHIVE_FILE_NAME} ./${BUILD_FILE_NAME}
+          tar -zcvf "${ARCHIVE_FILE_NAME}" ./"${BUILD_FILE_NAME}"
           mkdir -p output/artifacts
-          cp ${ARCHIVE_FILE_NAME} output/artifacts
-          sha256sum ${ARCHIVE_FILE_NAME} | head -c 64 > output/artifacts/${ARCHIVE_FILE_NAME}.sha256
-      - name: Create archive and checksum (Windows)
+          cp "${ARCHIVE_FILE_NAME}" output/artifacts
+          sha256sum "${ARCHIVE_FILE_NAME}" | head -c 64 > output/artifacts/"${ARCHIVE_FILE_NAME}".sha256
+      - name: Build on Windows
         if: ${{ startsWith(matrix.os, 'windows-') }}
         run: |
+          python -m pip install --upgrade pip
+          pip install -r build_configs/windows/requirements.txt
+          pyinstaller --distpath $env:BUILD_FILE_NAME .\build_configs\windows\build.spec
           $env:ZIP_FILE_NAME = ($env:BUILD_FILE_NAME + ".zip")
           echo ("ZIP_FILE_NAME=" + $env:ZIP_FILE_NAME) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
           Compress-Archive -Path $env:BUILD_FILE_NAME_PATH -DestinationPath $env:ZIP_FILE_NAME
@@ -111,25 +102,271 @@ jobs:
           copy $env:ZIP_FILE_NAME output\artifacts
           $env:CHECKSUM_FILE_NAME_PATH = ("output\artifacts\" + $env:ZIP_FILE_NAME + ".sha256")
           certUtil -hashfile $env:ZIP_FILE_NAME SHA256 | findstr /i /v "SHA256" | findstr /i /v "CertUtil" > $env:CHECKSUM_FILE_NAME_PATH
-      - name: Generate artifacts attestation (Linux & macOS)
-        if: ${{ startsWith(matrix.os, 'ubuntu-') || startsWith(matrix.os, 'macos-') }}
+      - name: Generate artifacts attestation
         uses: actions/attest-build-provenance@v1
         with:
           subject-path: output/artifacts/*
-      - name: Generate artifacts attestation (Windows)
-        if: ${{ startsWith(matrix.os, 'windows-') }}
-        uses: actions/attest-build-provenance@v1
-        with:
-          subject-path: output\artifacts\*
-      - name: Archive production artifacts (Linux & macOS)
-        if: ${{ startsWith(matrix.os, 'ubuntu-') || startsWith(matrix.os, 'macos-') }}
+      - name: Archive production artifacts
         uses: actions/upload-artifact@v4
         with:
           name: binary-${{ matrix.os }}-${{ github.sha }}-${{ github.run_id }}
           path: output/artifacts
-      - name: Archive production artifacts (Windows)
-        if: ${{ startsWith(matrix.os, 'windows-') }}
-        uses: actions/upload-artifact@v4
+  build-and-push-docker:
+    runs-on: ubuntu-latest
+    # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
+    permissions:
+      contents: read
+      packages: write
+      attestations: write
+      id-token: write
+    outputs:
+      metadata: ${{ steps.push.outputs.metadata }}
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+      # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
+      - name: Log in to the Container registry
+        # This pinned action came from docker/login-action@v3, releases can be found on https://github.com/docker/login-action/releases
+        uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
         with:
-          name: binary-${{ matrix.os }}-${{ github.sha }}-${{ github.run_id }}
-          path: output\artifacts
+          registry: ${{ env.REGISTRY }}
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
+      - name: Extract metadata (tags, labels) for Docker
+        id: meta
+        # This pinned action came from docker/metadata-action@v5, releases can be found on https://github.com/docker/metadata-action/releases
+        uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81
+        with:
+          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+      # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
+      # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
+      # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
+      - name: Build and push Docker image
+        id: push
+        # This pinned action came from docker/build-push-action@v6, releases can be found on https://github.com/docker/build-push-action/releases
+        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
+        with:
+          context: .
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+      
+      # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." 
+      - name: Generate artifact attestation
+        uses: actions/attest-build-provenance@v1
+        with:
+          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
+          subject-digest: ${{ steps.push.outputs.digest }}
+          push-to-registry: true
+  create-release:
+    needs: [build-binaries, build-and-push-docker]
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - uses: actions/checkout@v4
+      - name: Download build binaries
+        uses: actions/download-artifact@v4
+        with:
+          path: assets/
+          pattern: binary-*
+      - name: Create draft release
+        uses: actions/github-script@v7
+        env:
+          DOCKER_IMAGE_METADATA: '${{ needs.build-and-push-docker.outputs.metadata }}'
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            var path = require('path');
+            var fs = require('fs');
+
+            const { DOCKER_IMAGE_METADATA, REGISTRY, IMAGE_NAME } = process.env;
+
+            const dockerMetadata = JSON.parse(DOCKER_IMAGE_METADATA);
+
+            var tagName = '';
+            var dockerVersion = '';
+            if (context.eventName == 'push') {
+              const tagRegex = /(?:refs\/tags\/)?(v\d+\.\d+\.\d+)$/;
+
+              const match = context.ref.match(tagRegex);
+              if (match) {
+                tagName = match[1];
+                dockerVersion = tagName;
+              } else {
+                core.setFailed(`Cannot extract the tag version from ref value '${context.ref}'.`);
+              }
+
+            } else if (context.eventName == 'workflow_dispatch') {
+              tagName = `dev-${context.actor}-${context.sha.substring(0, 7)}-${context.runId}`;
+              dockerVersion = dockerMetadata['containerimage.digest'].replace(':', '-');
+            } else {
+              core.setFailed(`Unhandled triggering event.`);
+            }
+
+            console.log(`Creating draft release for tag ${tagName}...`)
+
+            console.log(`tagName: ${tagName}`);
+            console.log(`context.sha: ${context.sha}`);
+
+            const { data: release } = await github.rest.repos.createRelease({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              tag_name: tagName,
+              target_commitish: context.sha,
+              draft: true,
+              generate_release_notes: true,
+            });
+
+            console.log(`Release ${release.id} created.`);
+
+            let binariesMap = new Map();
+            const emptyBinaryObject = {
+              system: null,
+              architecture: null,
+              binary_archive: null,
+              binary_archive_download_url: null,
+              binary_checksum: null,
+              binary_checksum_download_url: null,
+              attestation: null,
+            };
+
+            const windowsSystem = 'Windows';
+            const macOSSystem = 'macOS';
+            const linuxSystem = 'Linux';
+
+            const amd64Architecture = 'x86_64';
+            const arm64Architecture = 'aarch64';
+
+            binariesMap.set('windows-amd64', Object.assign({}, emptyBinaryObject, {
+              system: windowsSystem,
+              architecture: amd64Architecture,
+            }));
+            binariesMap.set('windows-arm64', Object.assign({}, emptyBinaryObject, {
+              system: windowsSystem,
+              architecture: arm64Architecture,
+            }));
+            binariesMap.set('darwin-amd64', Object.assign({}, emptyBinaryObject, {
+              system: macOSSystem,
+              architecture: amd64Architecture,
+            }));
+            binariesMap.set('darwin-arm64', Object.assign({}, emptyBinaryObject, {
+              system: macOSSystem,
+              architecture: arm64Architecture,
+            }));
+            binariesMap.set('linux-amd64', Object.assign({}, emptyBinaryObject, {
+              system: linuxSystem,
+              architecture: amd64Architecture,
+            }));
+            binariesMap.set('linux-arm64', Object.assign({}, emptyBinaryObject, {
+              system: linuxSystem,
+              architecture: arm64Architecture,
+            }));
+
+            console.log('Uploading release assets...');
+
+            const binaryPlatformRegex = /(\w+)-(\w+)(?=\.(?:zip|tar\.gz)(.sha256)?$)/;
+
+            const archivesGlobber = await glob.create('assets/*/*')
+            for await (const file of archivesGlobber.globGenerator()) {
+              console.log(`Uploading ${path.basename(file)} to the release ${release.id}`);
+
+              const fileName = path.basename(file);
+              const fileContent = fs.readFileSync(file);
+
+              const { data: asset } = await github.rest.repos.uploadReleaseAsset({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                release_id: release.id,
+                name: fileName,
+                data: fileContent,
+              });
+
+              const match = fileName.match(binaryPlatformRegex);
+              if (match) {
+                const platform = `${match[1]}-${match[2]}`;
+
+                const binaryDetails = binariesMap.get(platform);
+
+                if (fileName.endsWith('.sha256')) {
+                  binariesMap.set(platform, Object.assign({}, binaryDetails, {
+                    binary_checksum: fileName,
+                    binary_checksum_download_url: asset.browser_download_url,
+                  }));
+                } else {
+                  binariesMap.set(platform, Object.assign({}, binaryDetails, {
+                    binary_archive: fileName,
+                    binary_archive_download_url: asset.browser_download_url,
+                  }));
+                }
+              }
+            }
+
+            const binariesTable = [
+              '| System  | Architecture | Binary             | Checksum               |',
+              '|---------|--------------|--------------------|------------------------|'
+            ];
+
+            binariesMap.forEach((details, platform) => {
+              if (
+                details.binary_archive !== null &&
+                details.binary_archive_download_url !== null &&
+                details.binary_checksum !== null &&
+                details.binary_checksum_download_url !== null
+              ) {
+                const system = details.system;
+                const architecture = details.architecture;
+                const binaryName = details.binary_archive;
+                const binaryUrl = details.binary_archive_download_url;
+                const checksumName = details.binary_checksum;
+                const checksumUrl = details.binary_checksum_download_url;
+
+                const binaryAssetUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/releases/download/${tagName}/${binaryName}`;
+                const checksumAssetUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/releases/download/${tagName}/${checksumName}`;
+
+                binariesTable.push(`| ${system} | ${architecture}       | [${binaryName}](${binaryAssetUrl}) | [sha256](${checksumAssetUrl}) |`);
+              }
+            });
+
+            const binariesTableContent = binariesTable.join('\n');
+
+            const dockerTable = [
+              '| Version | Name | Package |',
+              '|---------|------|---------|'
+            ];
+
+            dockerTable.push(`| ${dockerVersion}  | \`${REGISTRY}/${IMAGE_NAME}:${dockerVersion}\` | [Github Package](https://github.com/${context.repo.owner}/${context.repo.repo}/pkgs/container/ethstaker-deposit-cli) |`);
+
+            const dockerTableContent = dockerTable.join('\n');
+
+            const { data: workflowRun } = await github.rest.actions.getWorkflowRun({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id: context.runId,
+            });
+
+            let releaseBodyTemplate = fs.readFileSync('.github/release_template.md', { encoding: 'utf8'});
+
+            console.log('Removing comments in release template...');
+
+            releaseBodyTemplate = releaseBodyTemplate.replaceAll(/^\[comment\]:\s*<>\s*\((.*?)\)\s*$/gm, '');
+            releaseBodyTemplate = releaseBodyTemplate.trim();
+
+            let releaseBody = releaseBodyTemplate.replaceAll('`[GENERATED-RELEASE-NOTES]`', release.body);
+            releaseBody = releaseBody.replaceAll('`[BINARIES-TABLE]`', binariesTableContent);
+            releaseBody = releaseBody.replaceAll('`[WORKFLOW-RUN-URL]`', workflowRun.html_url);
+            releaseBody = releaseBody.replaceAll('`[DOCKER-TABLE]`', dockerTableContent);
+
+            console.log('Updating release body with generated content and template...');
+
+            const { data: updatedRelease } = await github.rest.repos.updateRelease({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              release_id: release.id,
+              tag_name: tagName,
+              target_commitish: context.sha,
+              body: releaseBody,
+            });
+
+            console.log(`Release ${updatedRelease.id} updated. Explore it on ${updatedRelease.html_url}`);
diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml
index b8907edf..fe75789e 100644
--- a/.github/workflows/comment.yml
+++ b/.github/workflows/comment.yml
@@ -1,6 +1,6 @@
 # Safely comment on the PR - https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
-name: Comment on pull request
-run-name: commenting on PR by ${{ github.actor }}
+name: ci-comment
+run-name: ${{ github.actor }} is adding coverage report comment on PR
 on:
   workflow_run:
     workflows: [ "ci-runner" ]
@@ -8,11 +8,52 @@ on:
       - completed
 
 jobs:
-  comment:
+  completed-coverage:
     runs-on: ubuntu-latest
     if: >
       github.event.workflow_run.event == 'pull_request' &&
       github.event.workflow_run.conclusion == 'success'
+    outputs:
+      coverage: ${{ steps.output.outputs.coverage }}
+    steps:
+      - name: Get coverage job success
+        uses: actions/github-script@v7
+        env:
+          WORKFLOW_RUN_ID: '${{ github.event.workflow_run.id }}'
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const { WORKFLOW_RUN_ID } = process.env;
+
+            core.exportVariable('COVERAGE', 'unknown');
+
+            const { data: allJobs } = await github.rest.actions.listJobsForWorkflowRun({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id: WORKFLOW_RUN_ID,
+            });
+
+            allJobs.jobs.forEach((job) => {
+              if (
+                job.name === 'coverage-combine' &&
+                job.status == 'completed' &&
+                job.conclusion == 'success'
+              ) {
+                core.exportVariable('COVERAGE', 'completed');
+              }
+            });
+      - name: Output coverage status
+        id: output
+        run: echo "coverage=${COVERAGE}" >> "$GITHUB_OUTPUT"
+  comment:
+    needs: completed-coverage
+    # Runs only if we have a completed coverage job from the ci-runner workflow
+    if: ${{ needs.completed-coverage.outputs.coverage == 'completed' }}
+    permissions:
+      issues: write
+      contents: write
+      pull-requests: write
+    runs-on: ubuntu-latest
     steps:
       - name: Download artifacts
         uses: actions/download-artifact@v4
@@ -21,7 +62,7 @@ jobs:
           run-id: ${{ github.event.workflow_run.id }}
           github-token: ${{ secrets.GITHUB_TOKEN }}
 
-      - name: 'Comment on PR'
+      - name: Comment on PR
         uses: actions/github-script@v7
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
deleted file mode 100644
index b3f8c3e9..00000000
--- a/.github/workflows/docker.yml
+++ /dev/null
@@ -1,65 +0,0 @@
-#
-name: ci-docker
-run-name: ${{ github.actor }} is running ci-docker
-
-# Configures this workflow to run every time a change is pushed to a tag that starts with `v`.
-on:
-  push:
-    tags:
-      - v*
-
-# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
-env:
-  REGISTRY: ghcr.io
-  IMAGE_NAME: ${{ github.repository }}
-
-# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
-jobs:
-  build-and-push-image:
-    runs-on: ubuntu-latest
-    # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
-    permissions:
-      contents: read
-      packages: write
-      attestations: write
-      id-token: write
-      # 
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-      # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
-      - name: Log in to the Container registry
-        # This pinned action came from docker/login-action@v3, releases can be found on https://github.com/docker/login-action/releases
-        uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
-        with:
-          registry: ${{ env.REGISTRY }}
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-      # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
-      - name: Extract metadata (tags, labels) for Docker
-        id: meta
-        # This pinned action came from docker/metadata-action@v5, releases can be found on https://github.com/docker/metadata-action/releases
-        uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81
-        with:
-          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
-      # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
-      # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
-      # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
-      - name: Build and push Docker image
-        id: push
-        # This pinned action came from docker/build-push-action@v6, releases can be found on https://github.com/docker/build-push-action/releases
-        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
-        with:
-          context: .
-          push: true
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
-      
-      # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." 
-      - name: Generate artifact attestation
-        uses: actions/attest-build-provenance@v1
-        with:
-          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
-          subject-digest: ${{ steps.push.outputs.digest }}
-          push-to-registry: true
-      
diff --git a/.github/workflows/runner.yml b/.github/workflows/runner.yml
index dc21d3c2..48ffdde2 100644
--- a/.github/workflows/runner.yml
+++ b/.github/workflows/runner.yml
@@ -1,7 +1,8 @@
 name: ci-runner
-run-name: ${{ github.actor }} is running ci-runner
+run-name: ${{ github.actor }} is testing and measuring code quality
 on:
   push:
+    branches: [main]
   pull_request:
     types: [opened, synchronize, labeled, unlabeled]
     branches: [main]
@@ -18,6 +19,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
+          pip install -r requirements.txt
           pip install -r requirements_test.txt
       - name: Run type checker
         run: python -m mypy --config-file mypy.ini -p ethstaker_deposit
@@ -31,9 +33,7 @@ jobs:
         run: npm install -g @prantlf/jsonlint@16.0.0
       - name: Validate JSON files
         run: |
-          for file in $(find ./ethstaker_deposit/intl -name "*.json"); do
-            jsonlint --no-duplicate-keys --quiet --compact $file
-          done
+          find ./ethstaker_deposit/intl -name "*.json" -exec jsonlint --no-duplicate-keys --quiet --compact {} \;
   ci-runner:
     needs: lint
     if: |
@@ -55,6 +55,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
+          pip install -r requirements.txt
           pip install -r requirements_test.txt
       - name: Run tests
         run: |
@@ -84,6 +85,7 @@ jobs:
         id: merge
         run: |
           python -m pip install --upgrade pip
+          pip install -r requirements.txt
           pip install -r requirements_test.txt
           coverage combine coverage*
           coverage html
@@ -96,11 +98,13 @@ jobs:
       - name: Generate text report
         run: |
           mkdir results
-          echo "Test Coverage: [Download HTML Report](${{ steps.upload-html.outputs.artifact-url }})" >> results/coverage-text-report
-          echo >> results/coverage-text-report
-          echo \`\`\` >> results/coverage-text-report
-          coverage report >> results/coverage-text-report
-          echo \`\`\` >> results/coverage-text-report
+          {
+            echo "Test Coverage: [Download HTML Report](${{ steps.upload-html.outputs.artifact-url }})" ;
+            echo ;
+            echo \`\`\` ;
+            coverage report ;
+            echo \`\`\` ;
+          } >> results/coverage-text-report
           echo ${{ github.event.number }} > results/issue_number
       - name: Upload final coverage text report
         uses: actions/upload-artifact@v4
diff --git a/docs/src/local_development.md b/docs/src/local_development.md
index 3f372ea7..c6618cc3 100644
--- a/docs/src/local_development.md
+++ b/docs/src/local_development.md
@@ -74,6 +74,7 @@ On Windows, you'll need:
 
 **To execute tests, you will need to install the test dependencies**:
 ```sh
+python3 -m pip install -r requirements.txt
 python3 -m pip install -r requirements_test.txt
 python3 -m pytest tests
 ```
diff --git a/docs/src/quick_setup.md b/docs/src/quick_setup.md
index 019b7212..dd00d204 100644
--- a/docs/src/quick_setup.md
+++ b/docs/src/quick_setup.md
@@ -29,16 +29,14 @@ For other installation options, including building with python or virtualenv and
 
 1. Make sure you have [the GitHub CLI installed](https://cli.github.com/).
 
-2. Download [the associated attestation](https://github.com/eth-educators/ethstaker-deposit-cli/attestations) for the archive you downloaded in Step 1.
-
-3. Verify the attestation file against the corresponding file but be sure to replace the contents with the exact file name:
+2. Verify the attestation against the corresponding file but be sure to replace the contents with the exact file name:
 ```sh
-gh attestation verify ethstaker_deposit-cli-*******-***.*** --owner eth-educators --bundle ./eth-educators-ethstaker-deposit-cli-attestation-*******.sigstore.json
+gh attestation verify ethstaker_deposit-cli-*******-***.*** --repo eth-educators/ethstaker-deposit-cli
 ```
 
 This step requires you to be online. If you want to perform this offline, follow [these instructions from GitHub](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/verifying-attestations-offline).
 
-4. You should see `✓ Verification succeeded!` in the output **otherwise do not continue**.
+3. You should see `✓ Verification succeeded!` in the output **otherwise do not continue**.
 
 ## Step 3: Usage
 
diff --git a/docs/src/release_process.md b/docs/src/release_process.md
index 3d198557..6d4b5f11 100644
--- a/docs/src/release_process.md
+++ b/docs/src/release_process.md
@@ -10,64 +10,13 @@ This document is meant as a guide on how to perform and publish a new release ve
 git tag -a -m 'Version 1.0.0' v1.0.0
 git push origin v1.0.0
 ```
-5. Wait for all the build assets to be created by [the ci-build workflow](https://github.com/eth-educators/ethstaker-deposit-cli/actions/workflows/build.yml). Wait for the docker image to be created by [the ci-docker workflow](https://github.com/eth-educators/ethstaker-deposit-cli/actions/workflows/docker.yml).
-6. Download all the release assets from [the build process](https://github.com/eth-educators/ethstaker-deposit-cli/actions/workflows/build.yml) in step 5. Extract the zip files to get the actual release files. Test some of the binary assets to make sure there is no major issue with them.
-7. Draft [a new release on Github](https://github.com/eth-educators/ethstaker-deposit-cli/releases/new).
-8. Choose the tag you created at step 4.
-9. Click the *Generate release notes* button. Copy the generated content to be included later.
-10. Add an interesting release title.
-11. Use [the template below](#release-notes-template) for the content of the release notes. Fill in the different sections correctly. Make sure all the links are updated to work with the new release including the various asset links and the docker image link. Include the generated release notes from step 9 in the *All changes* section.
-12. Upload all the binary assets extracted in step 6 in the draft release binaries section.
-13. If this is not a production release, check the *Set as a pre-release* checkbox.
-14. Click the *Publish release* button.
-15. Determine a new dev version number. You can try to guess the next version number to the best of your ability. This will always be subject to change. Add a `dev` identifier to the version number to clearly indicate this is a dev version number.
-16. Update `ethstaker_deposit/__init__.py`'s `__version__` variable with a new dev version number. Commit this change to the main branch.
+5. Wait for all the build assets and the draft release to be created by [the ci-build workflow](https://github.com/eth-educators/ethstaker-deposit-cli/actions/workflows/build.yml).
+6. Open the draft release and fill in the different sections correctly.
+7. If this is not a production release, check the *Set as a pre-release* checkbox.
+8. Click the *Publish release* button.
+9. Determine a new dev version number. You can try to guess the next version number to the best of your ability. This will always be subject to change. Add a `dev` identifier to the version number to clearly indicate this is a dev version number.
+10. Update `ethstaker_deposit/__init__.py`'s `__version__` variable with a new dev version number. Commit this change to the main branch.
 
 ## Release Notes Template
 
-You can start the release notes with this template:
-
-```markdown
-# Summary
-
-This preview release contains all the changes that were made since the original fork of the [staking-deposit-cli project](https://github.com/ethereum/staking-deposit-cli/) ([fdab65d commit](https://github.com/ethereum/staking-deposit-cli/commit/fdab65d33a63632e1935e9a9235119a46e37c221)).
-
-Notable changes from the original project include:
-
-- New exit commands to create an exit message and perform a voluntary exit for your validators.
-- Multiprocessing support to increase the speed of processes that can be expanded to use more than a single thread or a single process. This helps with generating a large number of validator keys for instance.
-- Support for more recent OSes and Python versions by default.
-- A dedicated [documentation website](https://eth-educators.github.io/ethstaker-deposit-cli/).
-
-# Known Issues
-
-`[Remove this section if there is no known issue]`
-
-# All changes
-
-`[Generated release notes]`
-
-# Building process
-
-Release assets were built using Github Actions and [this workflow run](https://github.com/eth-educators/ethstaker-deposit-cli/actions/runs/10113717389). You can establish the provenance of this build using [our artifact attestations](https://github.com/eth-educators/ethstaker-deposit-cli/attestations).
-
-# Binaries
-
-| System  | Architecture | Binary             | Checksum               | Attestation           |
-|---------|--------------|--------------------|------------------------|-----------------------|
-| Windows | x86_64       | [ethstaker_deposit-cli-c840111-windows-amd64.zip](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-windows-amd64.zip) | [sha256](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-windows-amd64.zip.sha256) | [Attestation](https://github.com/eth-educators/ethstaker-deposit-cli/attestations/2242278) |
-| macOS   | x86_64       | [ethstaker_deposit-cli-c840111-darwin-amd64.tar.gz](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-darwin-amd64.tar.gz) | [sha256](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-darwin-amd64.tar.gz.sha256) | [Attestation](https://github.com/eth-educators/ethstaker-deposit-cli/attestations/2242270) |
-| macOS   | aarch64      | [ethstaker_deposit-cli-c840111-darwin-arm64.tar.gz](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-darwin-arm64.tar.gz) | [sha256](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-darwin-arm64.tar.gz.sha256) | [Attestation](https://github.com/eth-educators/ethstaker-deposit-cli/attestations/2242259) |
-| Linux   | x86_64       | [ethstaker_deposit-cli-c840111-linux-amd64.tar.gz](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-linux-amd64.tar.gz) | [sha256](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-linux-amd64.tar.gz.sha256) | [Attestation](https://github.com/eth-educators/ethstaker-deposit-cli/attestations/2242267) |
-| Linux   | aarch64      | [ethstaker_deposit-cli-c840111-linux-arm64.tar.gz](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-linux-arm64.tar.gz) | [sha256](https://github.com/eth-educators/ethstaker-deposit-cli/releases/download/v0.1.0/ethstaker_deposit-cli-c840111-linux-arm64.tar.gz.sha256) | [Attestation](https://github.com/eth-educators/ethstaker-deposit-cli/attestations/2242273) |
-
-# Docker image
-
-| Version | Name | Package |
-|---------|------|---------|
-| v0.1.0  | `ghcr.io/eth-educators/ethstaker-deposit-cli:v0.1.0` | [Github Package](https://github.com/eth-educators/ethstaker-deposit-cli/pkgs/container/ethstaker-deposit-cli/249338184?tag=v0.1.0) |
-
-## License
-
-By downloading and using this software, you agree to the [license](LICENSE).
-```
\ No newline at end of file
+You can find the latest release notes template on https://github.com/eth-educators/ethstaker-deposit-cli/blob/main/.github/release_template.md .
\ No newline at end of file
diff --git a/ethstaker_deposit/bls_to_execution_change_keystore.py b/ethstaker_deposit/bls_to_execution_change_keystore.py
index 78c73f7c..590783b0 100644
--- a/ethstaker_deposit/bls_to_execution_change_keystore.py
+++ b/ethstaker_deposit/bls_to_execution_change_keystore.py
@@ -13,6 +13,9 @@
     compute_signing_root,
     compute_bls_to_execution_change_keystore_domain,
 )
+from ethstaker_deposit.utils.file_handling import (
+    sensitive_opener,
+)
 
 
 def bls_to_execution_change_keystore_generation(
@@ -64,8 +67,6 @@ def export_bls_to_execution_change_keystore_json(folder: str,
         'bls_to_execution_change_keystore_signature-%s-%i.json' % (index, timestamp)
     )
 
-    with open(filefolder, 'w') as f:
+    with open(filefolder, 'w', opener=sensitive_opener) as f:
         json.dump(signed_bls_to_execution_change_keystore_json, f)
-    if os.name == 'posix':
-        os.chmod(filefolder, int('440', 8))  # Read for owner & group
     return filefolder
diff --git a/ethstaker_deposit/credentials.py b/ethstaker_deposit/credentials.py
index f282b1fc..79c84f5b 100644
--- a/ethstaker_deposit/credentials.py
+++ b/ethstaker_deposit/credentials.py
@@ -38,6 +38,9 @@
     DepositMessage,
     SignedBLSToExecutionChange,
 )
+from ethstaker_deposit.utils.file_handling import (
+    sensitive_opener,
+)
 
 
 class WithdrawalType(Enum):
@@ -364,8 +367,6 @@ def export_bls_to_execution_change_json(self, folder: str, validator_indices: Se
                     bar.update(1)
 
         filefolder = os.path.join(folder, 'bls_to_execution_change-%i.json' % time.time())
-        with open(filefolder, 'w') as f:
+        with open(filefolder, 'w', opener=sensitive_opener) as f:
             json.dump(bls_to_execution_changes, f)
-        if os.name == 'posix':
-            os.chmod(filefolder, int('440', 8))  # Read for owner & group
         return filefolder
diff --git a/ethstaker_deposit/key_handling/keystore.py b/ethstaker_deposit/key_handling/keystore.py
index f5dc4f3c..5b7bc567 100644
--- a/ethstaker_deposit/key_handling/keystore.py
+++ b/ethstaker_deposit/key_handling/keystore.py
@@ -5,7 +5,7 @@
     field as dataclass_field
 )
 import json
-import os
+
 from py_ecc.bls import G2ProofOfPossession as bls
 from secrets import randbits
 from typing import Any, Dict, Union
@@ -21,6 +21,9 @@
 from ethstaker_deposit.utils.constants import (
     UNICODE_CONTROL_CHARS,
 )
+from ethstaker_deposit.utils.file_handling import (
+    sensitive_opener,
+)
 
 hexdigits = set('0123456789abcdef')
 
@@ -96,10 +99,8 @@ def save(self, filefolder: str) -> None:
         """
         Save self as a JSON keystore.
         """
-        with open(filefolder, 'w') as f:
+        with open(filefolder, 'w', opener=sensitive_opener) as f:
             f.write(self.as_json())
-        if os.name == 'posix':
-            os.chmod(filefolder, int('440', 8))  # Read for owner & group
 
     @classmethod
     def from_json(cls, json_dict: Dict[Any, Any]) -> 'Keystore':
diff --git a/ethstaker_deposit/utils/deposit.py b/ethstaker_deposit/utils/deposit.py
index 2ce78aef..3a5c7df6 100644
--- a/ethstaker_deposit/utils/deposit.py
+++ b/ethstaker_deposit/utils/deposit.py
@@ -1,11 +1,13 @@
 import json
 import os
 
+from ethstaker_deposit.utils.file_handling import (
+    sensitive_opener,
+)
+
 
 def export_deposit_data_json(folder: str, timestamp: float, deposit_data: list[dict[str, bytes]]) -> str:
     file_folder = os.path.join(folder, 'deposit_data-%i.json' % timestamp)
-    with open(file_folder, 'w') as f:
+    with open(file_folder, 'w', opener=sensitive_opener) as f:
         json.dump(deposit_data, f, default=lambda x: x.hex())
-    if os.name == 'posix':
-        os.chmod(file_folder, int('440', 8))  # Read for owner & group
     return file_folder
diff --git a/ethstaker_deposit/utils/exit_transaction.py b/ethstaker_deposit/utils/exit_transaction.py
index dfd7934e..69d720ff 100644
--- a/ethstaker_deposit/utils/exit_transaction.py
+++ b/ethstaker_deposit/utils/exit_transaction.py
@@ -10,6 +10,9 @@
     compute_signing_root,
     compute_voluntary_exit_domain,
 )
+from ethstaker_deposit.utils.file_handling import (
+    sensitive_opener,
+)
 
 
 def exit_transaction_generation(
@@ -55,8 +58,6 @@ def export_exit_transaction_json(folder: str, signed_exit: SignedVoluntaryExit,
         )
     )
 
-    with open(filefolder, 'w') as f:
+    with open(filefolder, 'w', opener=sensitive_opener) as f:
         json.dump(signed_exit_json, f)
-    if os.name == 'posix':
-        os.chmod(filefolder, int('440', 8))  # Read for owner & group
     return filefolder
diff --git a/ethstaker_deposit/utils/file_handling.py b/ethstaker_deposit/utils/file_handling.py
index f0142491..dfe84a25 100644
--- a/ethstaker_deposit/utils/file_handling.py
+++ b/ethstaker_deposit/utils/file_handling.py
@@ -1,5 +1,6 @@
 import os
 import sys
+from typing import Any, Union
 
 
 def resource_path(relative_path: str) -> str:
@@ -13,3 +14,14 @@ def resource_path(relative_path: str) -> str:
     except Exception:
         base_path = os.path.abspath(".")
     return os.path.join(base_path, relative_path)
+
+
+def sensitive_opener(path: Union[str, bytes, 'os.PathLike[Any]'], flags: int) -> int:
+    """
+    Opener to be used with the open built-in function to correctly assign permissions to sensitive
+    files when created and written to for the first time.
+    """
+    if os.name == 'posix':
+        return os.open(path, flags | os.O_EXCL, 0o400)
+    else:
+        return os.open(path, flags)


Test patch:
diff --git a/tests/test_cli/helpers.py b/tests/test_cli/helpers.py
index b42e170c..7271b998 100644
--- a/tests/test_cli/helpers.py
+++ b/tests/test_cli/helpers.py
@@ -60,7 +60,7 @@ def get_permissions(path: str, file_name: str) -> str:
 def verify_file_permission(os_ref, folder_path, files):
     if os_ref.name == 'posix':
         for file_name in files:
-            assert get_permissions(folder_path, file_name) == '0o440'
+            assert get_permissions(folder_path, file_name) == '0o400'
 
 
 def prepare_testing_folder(os_ref, testing_folder_name='TESTING_TEMP_FOLDER'):
diff --git a/tests/test_cli/test_existing_mnemonic.py b/tests/test_cli/test_existing_mnemonic.py
index db703e52..c340f167 100644
--- a/tests/test_cli/test_existing_mnemonic.py
+++ b/tests/test_cli/test_existing_mnemonic.py
@@ -51,7 +51,7 @@ def test_existing_mnemonic_bls_withdrawal() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
     # Clean up
     clean_key_folder(my_folder_path)
 
@@ -104,7 +104,7 @@ def test_existing_mnemonic_withdrawal_address() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
     # Clean up
     clean_key_folder(my_folder_path)
 
@@ -163,7 +163,7 @@ def test_existing_mnemonic_withdrawal_address_bad_checksum() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
     # Clean up
     clean_key_folder(my_folder_path)
 
@@ -289,7 +289,7 @@ async def test_script() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -338,7 +338,7 @@ async def test_script_abbreviated_mnemonic() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -393,7 +393,7 @@ def test_existing_mnemonic_custom_testnet() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
     # Clean up
     clean_key_folder(my_folder_path)
 
@@ -438,7 +438,7 @@ def test_existing_mnemonic_multiple_languages() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
     # Clean up
     clean_key_folder(my_folder_path)
 
@@ -484,6 +484,6 @@ def test_existing_mnemonic_multiple_languages_argument() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
     # Clean up
     clean_key_folder(my_folder_path)
diff --git a/tests/test_cli/test_new_mnemonic.py b/tests/test_cli/test_new_mnemonic.py
index 0a447705..7945a0a8 100644
--- a/tests/test_cli/test_new_mnemonic.py
+++ b/tests/test_cli/test_new_mnemonic.py
@@ -69,7 +69,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -125,7 +125,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -186,7 +186,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -243,7 +243,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -301,7 +301,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -359,7 +359,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -530,7 +530,7 @@ async def test_script_bls_withdrawal() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -609,7 +609,7 @@ async def test_script_abbreviated_mnemonic() -> None:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
@@ -665,7 +665,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in key_files:
-            assert get_permissions(validator_keys_folder_path, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(my_folder_path)
diff --git a/tests/test_cli/test_partial_deposit.py b/tests/test_cli/test_partial_deposit.py
index 8ab9ce12..6e1cfb64 100644
--- a/tests/test_cli/test_partial_deposit.py
+++ b/tests/test_cli/test_partial_deposit.py
@@ -68,7 +68,7 @@ def test_partial_deposit(amount: str) -> None:
     assert len(deposit_files) == 1
 
     if os.name == 'posix':
-        assert get_permissions(partial_deposit_folder, deposit_files[0]) == '0o440'
+        assert get_permissions(partial_deposit_folder, deposit_files[0]) == '0o400'
 
     clean_partial_deposit_folder(my_folder_path)
 
@@ -137,7 +137,7 @@ def test_partial_deposit_matches_existing_mnemonic_deposit() -> None:
     assert deposit_contents == partial_deposit_contents
 
     if os.name == 'posix':
-        assert get_permissions(partial_deposit_folder, partial_deposit_files[0]) == '0o440'
+        assert get_permissions(partial_deposit_folder, partial_deposit_files[0]) == '0o400'
 
     clean_folder(my_folder_path, validator_key_folder, True)
     clean_partial_deposit_folder(my_folder_path)
@@ -218,7 +218,7 @@ def test_partial_deposit_does_not_match_if_amount_differs() -> None:
     assert deposit_contents_dict["deposit_cli_version"] == partial_deposit_contents_dict["deposit_cli_version"]
 
     if os.name == 'posix':
-        assert get_permissions(partial_deposit_folder, partial_deposit_files[0]) == '0o440'
+        assert get_permissions(partial_deposit_folder, partial_deposit_files[0]) == '0o400'
 
     clean_folder(my_folder_path, validator_key_folder, True)
     clean_partial_deposit_folder(my_folder_path)
@@ -281,7 +281,7 @@ def test_partial_deposit_custom_network(amount: str) -> None:
     assert len(deposit_files) == 1
 
     if os.name == 'posix':
-        assert get_permissions(partial_deposit_folder, deposit_files[0]) == '0o440'
+        assert get_permissions(partial_deposit_folder, deposit_files[0]) == '0o400'
 
     clean_partial_deposit_folder(my_folder_path)
 
diff --git a/tests/test_cli/test_regeneration.py b/tests/test_cli/test_regeneration.py
index 160ce145..513f7032 100644
--- a/tests/test_cli/test_regeneration.py
+++ b/tests/test_cli/test_regeneration.py
@@ -56,7 +56,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in part_1_key_files:
-            assert get_permissions(validator_keys_folder_path_1, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path_1, file_name) == '0o400'
 
     # Part 2: existing-mnemonic
     runner = CliRunner()
@@ -98,7 +98,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
     # Verify file permissions
     if os.name == 'posix':
         for file_name in part_2_key_files:
-            assert get_permissions(validator_keys_folder_path_2, file_name) == '0o440'
+            assert get_permissions(validator_keys_folder_path_2, file_name) == '0o400'
 
     # Clean up
     clean_key_folder(folder_path_1)