Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 additions & 0 deletions .auths/allowed_signers
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Auths Allowed Signers
#
# This file lists the Ed25519 public keys of maintainers authorized to sign
# commits for this repository using Auths (https://github.com/auths-dev/auths).
#
# With Auths, every commit carries an Ed25519 signature bound to the
# maintainer's KERI-based decentralized identifier (DID). This creates a
# cryptographic chain of custody that cannot be forged with stolen registry
# credentials alone.
#
# Format:
# <DID>@auths.local namespaces="git" ssh-ed25519 <base64-public-key>
#
# To add your key:
# 1. Install Auths: pip install auths
# 2. Create identity: auths init
# 3. Configure Git: auths git setup
# 4. Export signers: auths git allowed-signers --output .auths/allowed_signers
# 5. Commit and push this file
#
# Documentation: https://github.com/auths-dev/auths/blob/main/docs/guides/platforms/ci-cd.md
#
# Maintainers — add your keys below this line:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty allowed_signers means zero cryptographic enforcement at merge time

The file contains no public keys. Until at least one maintainer adds their key, the Auths verification step has no authorized identities to validate commits against. Depending on the Auths CLI implementation, this will either:

  • Report every commit as unsigned/unverified (warn-only, so nothing is blocked), or
  • Vacuously pass verification (nothing to check against = no violations).

Either outcome means the "cryptographic chain of custody" described in the PR description doesn't exist the moment this merges. The workflow will run but provide no actual signal until keys are populated.

Consider making this a follow-up requirement: the workflow should only be merged once at least one maintainer key is present, otherwise it creates the impression of coverage without providing any.

37 changes: 37 additions & 0 deletions .github/workflows/auths-verify-commits.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Auths Commit Verification

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions: {}

jobs:
verify:
name: Verify commit signatures
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false

- name: Verify commits with Auths
uses: auths-dev/auths-verify-github-action@v1 # TODO: pin to SHA once stable
with:
allowed-signers: .auths/allowed_signers
fail-on-unsigned: 'false'
skip-merge-commits: 'true'
post-pr-comment: 'true'
github-token: ${{ secrets.GITHUB_TOKEN }}
36 changes: 36 additions & 0 deletions cookbook/security/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Security Cookbook: Auths Commit Verification

## Background

On March 24, 2026, LiteLLM was the target of a supply chain attack. The attacker
compromised the Trivy GitHub Action, which exfiltrated the `PYPI_PUBLISH` token from
LiteLLM's CI/CD pipeline. The stolen token was used to publish malicious versions
(v1.82.7 and v1.82.8) directly to PyPI. The source code on GitHub was never modified.

The attack succeeded because **there was no cryptographic binding between the published
package and a verified maintainer identity**.

## What is Auths?

[Auths](https://github.com/auths-dev/auths) provides Ed25519 signatures bound to
KERI-based decentralized identifiers (DIDs). With Auths:

- Every commit carries a signature from the maintainer's cryptographic identity
- The signature is bound to the maintainer's device keychain (not a registry account)
- Stealing PyPI/npm credentials is insufficient without the signing key
- Verification happens locally — no network calls to a central authority

## Running the Simulation

The simulation script recreates the attack scenario and demonstrates how Auths
verification catches the unauthorized commit:

```bash
pip install auths
python auths_attack_simulation.py
```

## Adding Auths to Your Workflow

See the GitHub Actions workflow at `.github/workflows/auths-verify-commits.yml`
and the allowed signers configuration at `.auths/allowed_signers`.
173 changes: 173 additions & 0 deletions cookbook/security/auths_attack_simulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
Auths Attack Simulation: LiteLLM March 24, 2026 Supply Chain Incident

Demonstrates how Auths cryptographic commit verification would have detected
the unauthorized PyPI publish that compromised LiteLLM v1.82.7 and v1.82.8.

What happened:
1. Attacker compromised the Trivy GitHub Action (March 19)
2. LiteLLM's CI ran Trivy without version pinning
3. Compromised Trivy exfiltrated the PYPI_PUBLISH token from GitHub Actions
4. Attacker used the stolen token to publish malicious versions to PyPI
5. The malicious packages contained a credential stealer in a .pth file
6. Source code on GitHub was never modified — the attack existed only in PyPI

Why Auths prevents this:
With Auths, every release artifact carries an Ed25519 signature from the
maintainer's cryptographic identity (KERI-based DID). Stealing the PyPI
token is insufficient — the attacker cannot produce a valid signature
without the maintainer's private key stored in their device keychain.

Usage:
pip install auths
python auths_attack_simulation.py
"""
import os
import shutil
import subprocess
import sys
import tempfile


def check_auths_cli() -> bool:
"""Check if the auths CLI is available."""
return shutil.which("auths") is not None


def run(cmd: list[str], cwd: str, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command and return the result."""
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=check,
)


def setup_test_repo(tmpdir: str) -> str:
"""Create a temporary git repo with signed and unsigned commits."""
repo = os.path.join(tmpdir, "litellm-simulation")
os.makedirs(repo)

# Initialize repo
run(["git", "init"], cwd=repo)
run(["git", "config", "user.email", "maintainer@example.com"], cwd=repo)
run(["git", "config", "user.name", "LiteLLM Maintainer"], cwd=repo)

# Generate a test Ed25519 keypair for the "legitimate maintainer"
key_path = os.path.join(tmpdir, "test_key")
run(
["ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "", "-q"],
cwd=tmpdir,
)

# Configure git to sign with this key
run(["git", "config", "gpg.format", "ssh"], cwd=repo)
run(["git", "config", "user.signingkey", key_path], cwd=repo)

# Create allowed_signers file
pub_key_content = open(f"{key_path}.pub").read().strip()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File opened without a context manager — resource leak

open() used directly without with leaves the file handle open until garbage collection. Use a context manager:

Suggested change
pub_key_content = open(f"{key_path}.pub").read().strip()
with open(f"{key_path}.pub") as fh:
pub_key_content = fh.read().strip()

signers_path = os.path.join(repo, ".auths")
os.makedirs(signers_path)
with open(os.path.join(signers_path, "allowed_signers"), "w") as f:
f.write(f"maintainer@example.com {pub_key_content}\n")

run(["git", "config", "gpg.ssh.allowedSignersFile", os.path.join(signers_path, "allowed_signers")], cwd=repo)

# Commit 1: Legitimate signed release (v1.82.6)
with open(os.path.join(repo, "litellm_version.py"), "w") as f:
f.write('version = "1.82.6"\n')
run(["git", "add", "."], cwd=repo)
run(["git", "commit", "-S", "-m", "release: v1.82.6 (legitimate, signed)"], cwd=repo)

# Commit 2: Attacker's malicious commit (unsigned — simulates PyPI-only publish)
run(["git", "config", "commit.gpgSign", "false"], cwd=repo)
with open(os.path.join(repo, "litellm_version.py"), "w") as f:
f.write('version = "1.82.7"\n')
with open(os.path.join(repo, "litellm_init.pth"), "w") as f:
f.write("# Simulated malicious payload — credential stealer\n")
f.write("import os; os.environ.get('AWS_SECRET_ACCESS_KEY') # exfiltrate\n")
run(["git", "add", "."], cwd=repo)
run(["git", "commit", "-m", "release: v1.82.7 (MALICIOUS — unsigned)"], cwd=repo)

return repo


def run_verification(repo: str) -> None:
"""Run auths verification and display results."""
result = run(
["auths", "verify", "HEAD~1..HEAD", "--allowed-signers", ".auths/allowed_signers"],
cwd=repo,
check=False,
)

if result.returncode != 0:
print(" BLOCKED: Unsigned commit detected")
if result.stdout:
print(f" Output: {result.stdout.strip()}")
if result.stderr:
print(f" Detail: {result.stderr.strip()}")
else:
print(" PASSED: All commits verified")
if result.stdout:
print(f" Output: {result.stdout.strip()}")


def main() -> None:
print("=" * 70)
print("Auths Attack Simulation: LiteLLM Supply Chain Incident (March 24, 2026)")
print("=" * 70)
print()

if not check_auths_cli():
print("The 'auths' CLI is not installed.")
print()
print("Install it with:")
print(" pip install auths")
print()
print("Or visit: https://github.com/auths-dev/auths")
sys.exit(0)

with tempfile.TemporaryDirectory() as tmpdir:
print("[1] Setting up simulation repository...")
repo = setup_test_repo(tmpdir)
print(" Created repo with 2 commits:")
print(" - v1.82.6: Legitimate release, signed by maintainer")
print(" - v1.82.7: Attacker's malicious version, unsigned")
print()

# Verify the legitimate commit
print("[2] Verifying legitimate commit (v1.82.6)...")
result = run(
["auths", "verify", "HEAD~2..HEAD~1", "--allowed-signers", ".auths/allowed_signers"],
cwd=repo,
check=False,
)
if result.returncode == 0:
print(" PASSED: Commit is signed by an authorized maintainer")
else:
print(" Result: ", result.stdout.strip() if result.stdout else result.stderr.strip())
print()

# Verify the malicious commit
print("[3] Verifying attacker's commit (v1.82.7)...")
run_verification(repo)
print()

# Summary
print("-" * 70)
print("RESULT: The attacker's unsigned commit would have been flagged.")
print()
print("In the real attack, the attacker used a stolen PyPI token to publish")
print("malicious packages directly to the registry. The source code on GitHub")
print("was never modified. With Auths, even if registry credentials are stolen,")
print("the attacker cannot produce a valid Ed25519 signature — the private key")
print("is bound to the maintainer's device keychain and never leaves it.")
print()
print("Learn more: https://github.com/auths-dev/auths")
print("=" * 70)


if __name__ == "__main__":
main()
Loading