OSS Contributor Identity Verification: Trust Levels, GPG Signing, and the Limits of DCO

OSS Contributor Identity Verification: Trust Levels, GPG Signing, and the Limits of DCO

The Problem

The standard identity controls applied to OSS contributors in 2024 — Developer Certificate of Origin sign-offs and Contributor License Agreements — verify one thing: that a GitHub account has agreed to a legal statement. They do not verify who is behind that account, whether the account represents a real person with a verifiable real-world identity, or whether the same person who built trust over months of legitimate contributions is the same person now submitting a security-critical change.

The xz-utils attack is the canonical example. The attacker, operating as “Jia Tan,” built a credible contributor identity over approximately two years. The account submitted real code that improved the project. It engaged in normal review discussions. It built social proof with other contributors. None of the standard controls caught it because none of the standard controls are designed to answer the question “is this GitHub account operated by the same real person it was six months ago?” — let alone “is this person who they say they are?”

DCO operates as a per-commit assertion. By adding Signed-off-by: Name <email> to a commit, a contributor asserts that they have the right to submit the contribution under the project’s license. This is a legal mechanism, not an identity mechanism. It does not require any verification — the assertion is entirely self-attestation. A fabricated identity can DCO sign commits indefinitely.

CLAs are slightly more rigorous for organizational contributors — a company CLA requires a legal representative to sign, which establishes corporate identity. But individual CLAs still verify only that a person with access to an email account has clicked agree. The email address in a CLA is not verified against a government identity document, a social security number, or anything that would establish real-world identity with confidence.

The practical consequence is that most OSS projects have no formal contributor trust model at all. The implicit model is: contributor submits PRs, maintainer reviews them, over time the maintainer develops a subjective sense of whether the contributor is trustworthy. This informal model scales poorly, degrades under pressure (maintainer burnout, high PR volume), and is specifically what long-game attackers exploit.

Formalising the trust model does not eliminate the risk — an attacker with sufficient patience can satisfy almost any objective criterion. But it raises the cost of an attack significantly, makes the trust state explicit and auditable, and creates checkpoints where anomalous patterns become visible.


Threat Model

Adversary 1 — Fabricated identity building OSS trust. The attacker creates a new GitHub account with a plausible name and email. They submit real, quality contributions over months or years to build a contributor record. They use consistent attribution across platforms (GitHub, mailing lists, conference talks) to build social proof. The attack payload is submitted once the account has obtained sufficient trust — merge rights, CODEOWNERS entry, or maintainer team membership. DCO sign-offs and CLA agreements are satisfied throughout; they are trivially satisfied by any account.

Adversary 2 — Compromised contributor account with valid GPG key. A legitimate contributor’s GitHub account is compromised via phishing or credential stuffing. The attacker gains access to the account and, critically, to the contributor’s GPG private key if it is stored on the same machine or cloud storage. GPG-signed commits from the compromised account are indistinguishable from legitimate commits. The signature proves the key, not the person.

Adversary 3 — Sock-puppet accounts providing social proof. A set of accounts controlled by the same attacker interact with each other to simulate community validation. Account A submits PRs; accounts B and C provide positive review comments, approve the PRs, and respond to maintainer questions in ways that simulate independent community members building trust in account A. This pattern is specifically designed to overcome maintainer scepticism about a new contributor.

Adversary 4 — Insider maintainer with real identity. A legitimate project maintainer with verified identity and a long contribution history decides to insert a backdoor. Real-world identity verification provides no defence here. The threat model for this adversary requires architectural controls — code review requirements, CODEOWNERS path restrictions, and audit logging — rather than identity verification.

Without controls: all four adversaries can operate indefinitely within the existing identity model. With controls: adversaries 1 and 3 face significantly higher friction from formal trust tier requirements and cross-project identity verification; adversary 2 is partially mitigated by hardware key requirements for Tier 2+; adversary 4 is addressed through architectural review controls rather than identity controls.


Hardening Configuration

Step 1 — Define a formal contributor trust tier model

Formalise the implicit trust model into an explicit tiered structure. Document this in CONTRIBUTING.md and in a separate SECURITY-TIERS.md file at the repository root:

# Contributor Trust Tiers

## Tier 0 — Unreviewed contributor
- Anyone with a GitHub account
- May submit pull requests
- All commits require full review from a Tier 2+ maintainer before merge
- May not approve or merge any PR
- May not be added to CODEOWNERS for any path
- DCO sign-off required on all commits

## Tier 1 — Established contributor
- Has 5+ merged PRs demonstrating quality contributions
- Nominated by a Tier 2+ maintainer after 90 days of activity
- May approve PRs for non-security-sensitive paths (subject to Tier 2+ final merge)
- May not merge — merge rights require Tier 2
- GPG-signed commits encouraged but not required
- Added to CONTRIBUTORS file

## Tier 2 — Trusted contributor (merge rights for specific paths)
- Has 20+ merged PRs over 180+ days of activity
- Approved by two existing Tier 2+ maintainers after review
- GPG-signed commits required for all commits
- Merge rights restricted to specific repository paths defined in path-acl.yml
- Added to CODEOWNERS for approved paths only
- Subject to annual revalidation

## Tier 3 — Core maintainer
- Full CODEOWNERS access including security-sensitive paths
- Requires verified cross-platform identity (Keyoxide profile or Sigstore Gitsign)
- Approved by all existing Tier 3 maintainers
- Hardware security key required for GPG signing
- Annual re-approval vote required

Step 2 — GPG signing policy and enforcement for Tier 2+

Configure the repository to require GPG-signed commits from Tier 2 and above contributors. First, establish the GPG policy in the repository’s branch protection configuration:

# Require signed commits on the main branch via GitHub API
curl -X PUT \
  -H "Authorization: Bearer $GITHUB_ADMIN_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  "https://api.github.com/repos/$ORG/$REPO/branches/main/protection" \
  -d '{
    "required_status_checks": {
      "strict": true,
      "contexts": ["ci/build", "ci/test", "security/commit-signature-check"]
    },
    "enforce_admins": true,
    "required_pull_request_reviews": {
      "required_approving_review_count": 1,
      "require_code_owner_reviews": true,
      "dismiss_stale_reviews": true
    },
    "required_signatures": true,
    "restrictions": null
  }'

Add a GitHub Actions workflow that validates commit signatures on PRs from contributors at or above Tier 1, and verifies that Tier 2+ contributors use a key that matches their registered fingerprint:

# .github/workflows/commit-signature-check.yml
name: Commit Signature Verification

on:
  pull_request:
    branches: [main]

permissions:
  contents: read
  statuses: write

jobs:
  verify-signatures:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install GnuPG and jq
        run: sudo apt-get install -y gnupg jq

      - name: Import trusted project GPG keys
        run: |
          # Import all trusted maintainer public keys from the repo's keyring
          # Keys are stored in .github/trusted-keys/ as armored .asc files
          for keyfile in .github/trusted-keys/*.asc; do
            gpg --import "$keyfile"
          done

      - name: Verify commits in PR
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          PR_AUTHOR: ${{ github.event.pull_request.user.login }}
        run: |
          python3 scripts/verify_pr_signatures.py \
            --base "$BASE_SHA" \
            --head "$HEAD_SHA" \
            --author "$PR_AUTHOR" \
            --tier-registry .github/contributor-tiers.yml
#!/usr/bin/env python3
# scripts/verify_pr_signatures.py
"""
Verify GPG commit signatures for PRs from Tier 2+ contributors.
Tier 0-1 contributors: signatures are checked but not required.
Tier 2+: unsigned or unverified-signature commits cause check failure.
"""

import argparse
import subprocess
import sys
import yaml

def get_pr_commits(base_sha: str, head_sha: str) -> list[str]:
    result = subprocess.run(
        ["git", "log", "--format=%H", f"{base_sha}..{head_sha}"],
        capture_output=True, text=True, check=True,
    )
    return result.stdout.strip().splitlines()

def verify_commit_signature(commit_sha: str) -> tuple[bool, str]:
    result = subprocess.run(
        ["git", "verify-commit", "--verbose", commit_sha],
        capture_output=True, text=True,
    )
    verified = result.returncode == 0
    output = result.stderr + result.stdout
    return verified, output

def get_contributor_tier(author: str, tier_registry: dict) -> int:
    for tier, members in tier_registry.get("tiers", {}).items():
        if author in (members or []):
            return int(tier.replace("tier", ""))
    return 0

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", required=True)
    parser.add_argument("--head", required=True)
    parser.add_argument("--author", required=True)
    parser.add_argument("--tier-registry", required=True)
    args = parser.parse_args()

    with open(args.tier_registry) as f:
        registry = yaml.safe_load(f)

    tier = get_contributor_tier(args.author, registry)
    commits = get_pr_commits(args.base, args.head)

    failures = []
    for sha in commits:
        verified, output = verify_commit_signature(sha)
        if not verified:
            failures.append((sha[:12], output))

    if tier >= 2 and failures:
        print(f"FAIL: Contributor {args.author} is Tier {tier} and has {len(failures)} "
              f"unsigned or unverified commits.")
        for sha, output in failures:
            print(f"  Commit {sha}: {output[:200]}")
        sys.exit(1)
    elif failures:
        print(f"WARN: {len(failures)} unsigned commits from Tier {tier} contributor "
              f"{args.author}. Signatures not required at this tier.")
    else:
        print(f"OK: All {len(commits)} commits verified for {args.author} (Tier {tier}).")

if __name__ == "__main__":
    main()

Step 3 — CODEOWNERS with security-sensitive path restrictions

Restrict who can approve changes to paths that affect security directly. The CODEOWNERS format uses glob patterns:

# .github/CODEOWNERS

# Default: any Tier 2+ maintainer
*                           @org/maintainers

# Security-sensitive paths: Tier 3 only
/crypto/                    @org/core-maintainers
/auth/                      @org/core-maintainers
/tls/                       @org/core-maintainers
/.github/workflows/         @org/core-maintainers
/.github/trusted-keys/      @org/core-maintainers
/configure.ac               @org/core-maintainers
/CMakeLists.txt             @org/core-maintainers
/Makefile                   @org/core-maintainers
/Cargo.lock                 @org/core-maintainers
/go.sum                     @org/core-maintainers
/package-lock.json          @org/core-maintainers

Step 4 — Cross-project identity verification via Sigstore Gitsign

For Tier 3 contributors, require identity verification that ties commit signatures to a verifiable identity outside GitHub. Sigstore Gitsign uses OpenID Connect to produce commit signatures that are verifiable against a Sigstore transparency log:

# Install gitsign
go install sigstore/gitsign@latest

# Configure git to use gitsign for signing
git config --global gpg.x509.program gitsign
git config --global gpg.format x509
git config --global commit.gpgsign true

# Verify a commit signed with gitsign
# This checks the Sigstore transparency log for the signing certificate
gitsign verify \
  --certificate-identity=maintainer@example.com \
  --certificate-oidc-issuer=https://accounts.google.com \
  HEAD

For Keyoxide-based identity verification, contributors publish a Keyoxide profile that cross-links their GPG key with other accounts (GitHub, Twitter/X, personal domain DNS). This creates a multi-factor identity proof that is significantly harder to fabricate than a single GitHub account:

# Keyoxide verification — check that a contributor's key matches their Keyoxide profile
# The profile URL is stored in contributor-tiers.yml under keyoxide_profile

# Contributors add a notation to their GPG key pointing to their Keyoxide proof
gpg --cert-notation proof@ariadne.id=https://keyoxide.org/hkp/FINGERPRINT \
    --edit-key FINGERPRINT

# Automated verification in CI:
# 1. Retrieve the contributor's Keyoxide profile
# 2. Verify the GitHub claim in the profile points to their actual account
# 3. Verify the GPG key fingerprint matches the signing key used in commits
# .github/contributor-tiers.yml
tiers:
  tier3:
    - alice
    - bob
  tier2:
    - carol
    - dave
    - erin

identity_verification:
  alice:
    gpg_fingerprint: "A1B2 C3D4 E5F6 7890 ABCD  EF01 2345 6789 ABCD EF01"
    keyoxide_profile: "https://keyoxide.org/hkp/A1B2C3D4E5F67890ABCDEF0123456789ABCDEF01"
    hardware_key: true
    verified_date: "2025-11-15"
  bob:
    gpg_fingerprint: "B2C3 D4E5 F6A7 8901 BCDE  F012 3456 789A BCDE F012"
    keyoxide_profile: "https://keyoxide.org/hkp/B2C3D4E5F6A78901BCDEF0123456789ABCDEF012"
    hardware_key: true
    verified_date: "2025-09-03"

Expected Behaviour After Hardening

With the tier model in place, a new contributor’s first PRs require full review from a Tier 2+ maintainer before merge. Self-merge is architecturally prevented — the contributor does not have the GitHub permissions needed to merge their own PRs until they reach Tier 2. The 90-day minimum and 5 PR threshold for Tier 1 mean that an attacker cannot fast-track to review rights.

For a Tier 2+ contributor, the commit signature check runs on every PR. A commit without a valid GPG signature from a registered fingerprint causes the status check to fail, and branch protection prevents the PR from being merged without manual override. The CODEOWNERS configuration ensures that changes to cryptographic code, build system files, and GitHub Actions workflows require a second sign-off from a Tier 3 maintainer.

The tier registry is itself a security-sensitive document — it controls who has what level of trust. Under the CODEOWNERS configuration, changes to .github/contributor-tiers.yml require Tier 3 approval. An attacker who wants to escalate a fabricated account’s tier must convince existing Tier 3 maintainers to approve the registry change, which requires satisfying the objective criteria and passing manual review.


Trade-offs and Operational Considerations

Consideration Detail
Tier advancement creates friction for legitimate contributors A new contributor who submits a high-quality security fix must wait 90 days and accumulate 5 PRs before getting Tier 1 status. This is the intended trade-off: the friction that slows attackers also slows legitimate contributors. Make the tier criteria explicit and the advancement process transparent so contributors understand the path.
GPG key management burden Requiring hardware security keys for Tier 3 is correct security posture but creates operational burden — lost YubiKeys, key renewal, key compromise response. Publish a key revocation procedure and test it annually.
Sigstore Gitsign vs traditional GPG Gitsign produces ephemeral signing certificates from OIDC tokens, which means commits are verifiable without requiring contributors to manage long-lived GPG keys. The trade-off is dependency on the Sigstore transparency log and OIDC provider availability. For projects with global maintainers, Gitsign is increasingly preferred.
Social proof attacks remain viable The tier model does not prevent sock-puppet networks from providing social proof for a fabricated account. Detection of this pattern requires monitoring reviewer account ages and cross-project contributor graphs, as described in the companion anomaly detection article.
Insider threat limitation Identity verification provides no protection against a verified maintainer with malicious intent. For this adversary, the mitigations are architectural: required PR reviews, path restrictions, and audit logging.
Annual revalidation overhead Requiring annual re-approval votes for Tier 3 maintainers is overhead, but it creates a regular checkpoint where inactive maintainers are removed, compromised accounts are identified, and the trust model is kept current.

Failure Modes

Failure Mode Cause Detection Mitigation
Tier registry not kept current Maintainer leaves project but remains in Tier 2/3; account is later compromised No automated detection of stale entries Add automated check: flag Tier 2+ accounts with no commit activity in 180 days; annual review process for all tier entries
GPG private key compromise with hardware key not required Tier 1 contributor’s key is stolen from unencrypted disk; attacker submits signed commits Commits from unfamiliar IP ranges or locations Require hardware keys for Tier 2+ as stated in tier model; add anomalous commit location alerting
CODEOWNERS bypass via branch rename Attacker with Tier 2 creates a branch named to exploit a CODEOWNERS glob gap CODEOWNERS does not cover new branch name patterns Regularly audit CODEOWNERS glob coverage; require Tier 3 sign-off for any branch pattern not covered
Tier advancement criteria gamed at low quality Attacker submits 5 trivial one-line PRs to reach Tier 1 threshold Quality bar for PRs is not enforced automatically Document that Tier advancement is based on quality review by Tier 2+ maintainer, not just PR count; make PRs non-trivial in description
Keyoxide profile fabricated Attacker creates a convincing Keyoxide profile with GitHub claim pointing to their account Verification passes; identity appears credible Keyoxide cannot prevent a real GitHub account from publishing a real key with a claim pointing to itself — the account’s history and code quality remain the most important signal
Signature check bypassed by admin override Maintainer overrides required signature check to merge a time-sensitive fix Override logged in GitHub audit log but may not be reviewed Configure alerts for branch protection override events; require written justification in PR comment when override is used