Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 178 additions & 16 deletions .github/workflows/mirror-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
# `vars.<FORGE>_MIRROR_ENABLED == 'true'`, so forge selection is
# configured per-repo via Actions vars — no per-call inputs required.
#
# Caller (one-line wrapper) MUST use `secrets: inherit` so the reusable
# Callers pass explicit secrets (preferred) or `secrets: inherit` so the reusable
# can read the per-forge SSH keys (GITLAB_SSH_KEY, BITBUCKET_SSH_KEY,
# CODEBERG_SSH_KEY, SOURCEHUT_SSH_KEY, DISROOT_SSH_KEY, GITEA_SSH_KEY)
# and RADICLE_KEY from the caller repo. Without `secrets: inherit`,
# and RADICLE_KEY from the caller repo. Without a passed secret,
# `${{ secrets.X }}` inside the reusable evaluates to empty.
#
# Caller example (wrapper):
Expand Down Expand Up @@ -44,12 +44,30 @@ on:
required: false
default: ubuntu-latest

secrets:
GITLAB_SSH_KEY:
required: false
BITBUCKET_SSH_KEY:
required: false
CODEBERG_SSH_KEY:
required: false
SOURCEHUT_SSH_KEY:
required: false
DISROOT_SSH_KEY:
required: false
GITEA_SSH_KEY:
required: false
RADICLE_KEY:
required: false

permissions:
actions: read
contents: read

jobs:
mirror-gitlab:
# Advisory mirror: verification still fails closed and skips the push.
continue-on-error: true
timeout-minutes: 20
runs-on: ${{ inputs.runs-on }}
if: vars.GITLAB_MIRROR_ENABLED == 'true'
Expand All @@ -67,6 +85,27 @@ jobs:
if: ${{ env.GITLAB_KEY != '' }}
with:
ssh-private-key: ${{ secrets.GITLAB_SSH_KEY }}
- name: Verify gitlab SSH host
if: ${{ env.GITLAB_KEY != '' }}
env:
MIRROR_HOST: gitlab.com
APPROVED_FINGERPRINT: ${{ vars.GITLAB_SSH_FINGERPRINT || 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' }}
run: |
set -euo pipefail
# Approved independently of ssh-keyscan; source: https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints
if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
[[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Configure a valid GITLAB_SSH_FINGERPRINT and mirror hostname"
exit 1
fi
CANDIDATE="$RUNNER_TEMP/mirror-gitlab-known-hosts"
ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE"
ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u)
if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then
echo "::error::Mirror SSH host fingerprint mismatch; refusing push"
exit 1
fi
printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV"
- name: Mirror to GitLab
# continue-on-error: GitLab branch protection on the mirror repo may block
# force-push even for a deploy key. Owner action required: in GitLab go to
Expand All @@ -75,9 +114,12 @@ jobs:
# Until then this step is advisory-only; failures do not red main.
continue-on-error: true
if: ${{ env.GITLAB_KEY != '' }}
env:
MIRROR_ORG: ${{ vars.GITLAB_ORG || vars.MIRROR_ORG || github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MIRROR_HOST: gitlab.com
run: |
ssh-keyscan -t ed25519 gitlab.com >> ~/.ssh/known_hosts
git remote add gitlab git@gitlab.com:hyperpolymath/${{ github.event.repository.name }}.git || true
git remote add gitlab "git@gitlab.com:${MIRROR_ORG}/${REPO_NAME}.git" || true
git push --force gitlab main
- name: Skipped (GITLAB_SSH_KEY not configured)
if: ${{ env.GITLAB_KEY == '' }}
Expand All @@ -100,11 +142,35 @@ jobs:
if: ${{ env.BITBUCKET_KEY != '' }}
with:
ssh-private-key: ${{ secrets.BITBUCKET_SSH_KEY }}
- name: Verify bitbucket SSH host
if: ${{ env.BITBUCKET_KEY != '' }}
env:
MIRROR_HOST: bitbucket.org
APPROVED_FINGERPRINT: ${{ vars.BITBUCKET_SSH_FINGERPRINT || 'SHA256:ybgmFkzwOSotHTHLJgHO0QN8L0xErw6vd0VhFA9m3SM' }}
run: |
set -euo pipefail
# Approved independently of ssh-keyscan; source: https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/
if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
[[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Configure a valid BITBUCKET_SSH_FINGERPRINT and mirror hostname"
exit 1
fi
CANDIDATE="$RUNNER_TEMP/mirror-bitbucket-known-hosts"
ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE"
ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u)
if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then
echo "::error::Mirror SSH host fingerprint mismatch; refusing push"
exit 1
fi
printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV"
- name: Mirror to Bitbucket
if: ${{ env.BITBUCKET_KEY != '' }}
env:
MIRROR_ORG: ${{ vars.BITBUCKET_ORG || vars.MIRROR_ORG || github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MIRROR_HOST: bitbucket.org
run: |
ssh-keyscan -t ed25519 bitbucket.org >> ~/.ssh/known_hosts
git remote add bitbucket git@bitbucket.org:hyperpolymath/${{ github.event.repository.name }}.git || true
git remote add bitbucket "git@bitbucket.org:${MIRROR_ORG}/${REPO_NAME}.git" || true
git push --force bitbucket main
- name: Skipped (BITBUCKET_SSH_KEY not configured)
if: ${{ env.BITBUCKET_KEY == '' }}
Expand All @@ -127,11 +193,35 @@ jobs:
if: ${{ env.CODEBERG_KEY != '' }}
with:
ssh-private-key: ${{ secrets.CODEBERG_SSH_KEY }}
- name: Verify codeberg SSH host
if: ${{ env.CODEBERG_KEY != '' }}
env:
MIRROR_HOST: codeberg.org
APPROVED_FINGERPRINT: ${{ vars.CODEBERG_SSH_FINGERPRINT || 'SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g' }}
run: |
set -euo pipefail
# Approved independently of ssh-keyscan; source: https://docs.codeberg.org/security/ssh-fingerprint/
if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
[[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Configure a valid CODEBERG_SSH_FINGERPRINT and mirror hostname"
exit 1
fi
CANDIDATE="$RUNNER_TEMP/mirror-codeberg-known-hosts"
ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE"
ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u)
if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then
echo "::error::Mirror SSH host fingerprint mismatch; refusing push"
exit 1
fi
printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV"
- name: Mirror to Codeberg
if: ${{ env.CODEBERG_KEY != '' }}
env:
MIRROR_ORG: ${{ vars.CODEBERG_ORG || vars.MIRROR_ORG || github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MIRROR_HOST: codeberg.org
run: |
ssh-keyscan -t ed25519 codeberg.org >> ~/.ssh/known_hosts
git remote add codeberg git@codeberg.org:hyperpolymath/${{ github.event.repository.name }}.git || true
git remote add codeberg "git@codeberg.org:${MIRROR_ORG}/${REPO_NAME}.git" || true
git push --force codeberg main
- name: Skipped (CODEBERG_SSH_KEY not configured)
if: ${{ env.CODEBERG_KEY == '' }}
Expand All @@ -154,11 +244,35 @@ jobs:
if: ${{ env.SOURCEHUT_KEY != '' }}
with:
ssh-private-key: ${{ secrets.SOURCEHUT_SSH_KEY }}
- name: Verify sourcehut SSH host
if: ${{ env.SOURCEHUT_KEY != '' }}
env:
MIRROR_HOST: git.sr.ht
APPROVED_FINGERPRINT: ${{ vars.SOURCEHUT_SSH_FINGERPRINT }}
run: |
set -euo pipefail
# Approved independently of ssh-keyscan; source: https://man.sr.ht/
if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
[[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Configure a valid SOURCEHUT_SSH_FINGERPRINT and mirror hostname"
exit 1
fi
CANDIDATE="$RUNNER_TEMP/mirror-sourcehut-known-hosts"
ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE"
ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u)
if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then
echo "::error::Mirror SSH host fingerprint mismatch; refusing push"
exit 1
fi
printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV"
- name: Mirror to SourceHut
if: ${{ env.SOURCEHUT_KEY != '' }}
env:
MIRROR_ORG: ${{ vars.SOURCEHUT_ORG || vars.MIRROR_ORG || github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MIRROR_HOST: git.sr.ht
run: |
ssh-keyscan -t ed25519 git.sr.ht >> ~/.ssh/known_hosts
git remote add sourcehut git@git.sr.ht:~hyperpolymath/${{ github.event.repository.name }} || true
git remote add sourcehut "git@git.sr.ht:~${MIRROR_ORG}/${REPO_NAME}" || true
git push --force sourcehut main
- name: Skipped (SOURCEHUT_SSH_KEY not configured)
if: ${{ env.SOURCEHUT_KEY == '' }}
Expand All @@ -181,11 +295,35 @@ jobs:
if: ${{ env.DISROOT_KEY != '' }}
with:
ssh-private-key: ${{ secrets.DISROOT_SSH_KEY }}
- name: Verify disroot SSH host
if: ${{ env.DISROOT_KEY != '' }}
env:
MIRROR_HOST: git.disroot.org
APPROVED_FINGERPRINT: ${{ vars.DISROOT_SSH_FINGERPRINT }}
run: |
set -euo pipefail
# Caller must approve this independently of the ssh-keyscan result.
if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
[[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Configure a valid DISROOT_SSH_FINGERPRINT and mirror hostname"
exit 1
fi
CANDIDATE="$RUNNER_TEMP/mirror-disroot-known-hosts"
ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE"
ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u)
if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then
echo "::error::Mirror SSH host fingerprint mismatch; refusing push"
exit 1
fi
printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV"
- name: Mirror to Disroot
if: ${{ env.DISROOT_KEY != '' }}
env:
MIRROR_ORG: ${{ vars.DISROOT_ORG || vars.MIRROR_ORG || github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MIRROR_HOST: git.disroot.org
run: |
ssh-keyscan -t ed25519 git.disroot.org >> ~/.ssh/known_hosts
git remote add disroot git@git.disroot.org:hyperpolymath/${{ github.event.repository.name }}.git || true
git remote add disroot "git@git.disroot.org:${MIRROR_ORG}/${REPO_NAME}.git" || true
git push --force disroot main
- name: Skipped (DISROOT_SSH_KEY not configured)
if: ${{ env.DISROOT_KEY == '' }}
Expand All @@ -208,11 +346,35 @@ jobs:
if: ${{ env.GITEA_KEY != '' }}
with:
ssh-private-key: ${{ secrets.GITEA_SSH_KEY }}
- name: Verify gitea SSH host
if: ${{ env.GITEA_KEY != '' }}
env:
MIRROR_HOST: ${{ vars.GITEA_HOST }}
APPROVED_FINGERPRINT: ${{ vars.GITEA_SSH_FINGERPRINT }}
run: |
set -euo pipefail
# Approved independently of ssh-keyscan; source: caller-controlled instance
if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
[[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Configure a valid GITEA_SSH_FINGERPRINT and mirror hostname"
exit 1
fi
CANDIDATE="$RUNNER_TEMP/mirror-gitea-known-hosts"
ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE"
ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u)
if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then
echo "::error::Mirror SSH host fingerprint mismatch; refusing push"
exit 1
fi
printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV"
- name: Mirror to Gitea
if: ${{ env.GITEA_KEY != '' }}
env:
MIRROR_ORG: ${{ vars.GITEA_ORG || vars.MIRROR_ORG || github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
MIRROR_HOST: ${{ vars.GITEA_HOST }}
run: |
ssh-keyscan -t ed25519 ${{ vars.GITEA_HOST }} >> ~/.ssh/known_hosts
git remote add gitea git@${{ vars.GITEA_HOST }}:hyperpolymath/${{ github.event.repository.name }}.git || true
git remote add gitea "git@${MIRROR_HOST}:${MIRROR_ORG}/${REPO_NAME}.git" || true
git push --force gitea main
- name: Skipped (GITEA_SSH_KEY not configured)
if: ${{ env.GITEA_KEY == '' }}
Expand Down Expand Up @@ -252,12 +414,12 @@ jobs:
if: ${{ env.RADICLE_KEY != '' }}
run: |
cargo install radicle-cli --locked
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Mirror to Radicle
if: ${{ env.RADICLE_KEY != '' }}
run: |
mkdir -p ~/.radicle/keys
echo "${{ secrets.RADICLE_KEY }}" > ~/.radicle/keys/radicle
printf "%s\n" "$RADICLE_KEY" > ~/.radicle/keys/radicle
chmod 600 ~/.radicle/keys/radicle
rad sync --announce || echo "Radicle sync attempted"
- name: Skipped (RADICLE_KEY not configured)
Expand Down
44 changes: 44 additions & 0 deletions docs/MIRROR-HOST-VERIFICATION.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MPL-2.0
= Mirror SSH host verification

The reusable mirror workflow verifies the remote Ed25519 host fingerprint
before exporting a strict SSH configuration or pushing any refs. An empty,
malformed, changed, or unapproved host key fails that mirror job. The fetched
key is never trusted merely because `ssh-keyscan` returned it. The GitLab
mirror remains advisory at job level: a verification failure skips the push
and records the job failure without failing the overall workflow.

Callers enable each mirror with its optional secret: `GITLAB_SSH_KEY`,
`BITBUCKET_SSH_KEY`, `CODEBERG_SSH_KEY`, `SOURCEHUT_SSH_KEY`, `DISROOT_SSH_KEY`,
`GITEA_SSH_KEY`, or `RADICLE_KEY`. Prefer explicit secret mappings in callers.
An absent secret leaves that mirror disabled.

GitLab, Bitbucket, and Codeberg have published fingerprints pinned
in the workflow. Their official reference pages are:

* https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints[GitLab]
* https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/[Bitbucket]
* https://docs.codeberg.org/security/ssh-fingerprint/[Codeberg]

SourceHut requires `SOURCEHUT_SSH_FINGERPRINT`, and Disroot requires
`DISROOT_SSH_FINGERPRINT`; no independently verified default is supplied for
either forge. A custom Gitea mirror requires
both `GITEA_HOST` and `GITEA_SSH_FINGERPRINT`. Obtain these values through an
authenticated administrative channel or the forge's published host-key
documentation. Do not populate them from the network scan being verified.

For an approved host-key rotation, set the corresponding repository or
organisation variable `<FORGE>_SSH_FINGERPRINT` to the approved `SHA256:...`
value. The six SSH forges support this override. The expected value contains
one Ed25519 fingerprint, with no surrounding whitespace or key material.

Mirror organisation selection is `<FORGE>_ORG`, then `MIRROR_ORG`, then the
GitHub repository owner. This preserves per-forge destinations while allowing
a shared destination for transferred repositories.

`scripts/tests/science-ci-security-test.sh` launches the assertions in
`scripts/tests/science-ci-security-test.rb`, which exercise every actual SSH
verification step with the real `ssh-keygen` fingerprint implementation. It
tests approved, tampered, absent, malformed, and unapproved keys, including
that failure cannot export a trusted SSH configuration. The same suite checks
that the Hypatia source checkout matches its resolved cache identity.
Loading
Loading