A GitHub Action that requires cryptographic signatures from hardware wallets to approve pull requests. Adds a second factor to PR approvals that can't be compromised by a stolen GitHub session or token.
GitHub's PR approval model is entirely account-based. If someone compromises a GitHub account (session hijack, stolen token, compromised SSO), they can approve PRs. This action adds a requirement that approvers sign a message with an offline hardware signing device (Ledger, Trezor, or any Ethereum-compatible wallet).
Optional blockchain canary: The action can validate that the signing key's associated wallet holds a minimum balance. If the balance is drained, it signals compromise and approvals automatically stop working - with zero manual intervention.
This project uses cryptographic signatures and optionally queries blockchain balances, but:
- Not an ICO - There is no token, no investment opportunity, nothing to buy
- Not an NFT - No digital collectibles, no ownership certificates
- Not blockchain-based git history - Your commits are not recorded on any blockchain
- Not Web3 - No smart contracts, no DeFi, no decentralized anything
This is simply using well-established elliptic curve cryptography (the same math that secures Bitcoin/Ethereum) as a second factor for PR approvals. The optional blockchain canary is just a method to detect key compromise - it reads a balance, nothing more.
┌─────────────────────────────────────────────────────────────────────────────┐
│ PR Approval Flow │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Approver │ │ MetaMask │ │ GitHub │ │ Action │
│ │ │ / Ledger │ │ PR │ │ │
└────┬─────┘ └──────┬───────┘ └──────┬──────┘ └──────┬───────┘
│ │ │ │
│ 1. Review PR │ │ │
│─────────────────────────────────────► │ │
│ │ │ │
│ 2. Open signing tool │ │
│────────────────► │ │ │
│ │ │ │
│ 3. Sign message │ │ │
│ (repo, PR#, │ │ │
│ SHA, timestamp)│ │ │
│◄────────────────►│ │ │
│ │ │ │
│ 4. Post signed comment │ │
│─────────────────────────────────────► │ │
│ │ │ │
│ │ │ 5. Trigger │
│ │ │─────────────────► │
│ │ │ │
│ │ │ 6. Parse comment │
│ │ │ 7. Verify sig │
│ │ │ 8. Check canary │
│ │ │ (optional) │
│ │ │ │
│ │ │ 9. Set commit │
│ │ │◄──────────────────│
│ │ │ status │
│ │ │ │
┌────▼─────┐ ┌──────▼───────┐ ┌──────▼──────┐ ┌──────▼───────┐
│ Approver │ │ MetaMask │ │ GitHub │ │ Action │
│ │ │ / Ledger │ │ PR │ │ │
└──────────┘ └──────────────┘ └─────────────┘ └──────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Blockchain Canary (Optional) │
└─────────────────────────────────────────────────────────────────────────────┘
Normal state: Compromise detected:
┌─────────────┐ ┌─────────────┐
│ Wallet │ │ Wallet │
│ Balance: │ │ Balance: │
│ 0.05 ETH │ ✓ Approved │ 0.00 ETH │ ✗ Rejected
│ ───────── │ │ ───────── │
│ Threshold: │ │ Threshold: │
│ 0.01 ETH │ │ 0.01 ETH │
└─────────────┘ └─────────────┘
If attacker steals seed phrase and drains wallet,
all repos using this key automatically reject approvals.
Create .github/KEYOWNERS with your authorized signer addresses:
# Authorized signers for crypto-approval
0xYourAddress1 # Alice - Security Lead
0xYourAddress2 # Bob - Platform Team
Create .github/workflows/crypto-approval.yml:
name: Crypto Approval
on:
pull_request:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
issues: read
statuses: write
jobs:
verify-approval:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || (github.event_name == 'issue_comment' && github.event.issue.pull_request)
steps:
- uses: actions/checkout@v4
- uses: Jamf-Concepts/crypto-approval-action@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Reads from .github/KEYOWNERS by default
# Or use: allowed-keys: '0xAddr1,0xAddr2'Use the hosted signing tool:
https://jamf-concepts.github.io/crypto-approval-action/
Or run locally:
git clone https://github.com/Jamf-Concepts/crypto-approval-action
cd crypto-approval-action
python3 -m http.server 8080
# Open http://localhost:8080/tools/sign-with-metamask.htmlEnter your PR details, sign with MetaMask, and paste the comment on the PR.
In your repo settings, add crypto-approval as a required status check.
| Input | Required | Default | Description |
|---|---|---|---|
allowed-keys |
No | Comma-separated Ethereum addresses authorized to approve | |
keyowners-file |
No | .github/KEYOWNERS |
Path to KEYOWNERS file listing authorized addresses |
min-approvals |
No | 1 |
Minimum unique signers required (M-of-N multi-sig) |
github-token |
Yes | GitHub token for API access | |
require-canary |
No | false |
Enable blockchain balance check |
canary-chain |
No | ethereum |
Chain to check (ethereum, base, polygon) |
canary-min-balance |
No | 10000000000000000 |
Minimum balance in wei (0.01 ETH) |
signature-max-age |
No | 3600 |
Maximum signature age in seconds |
| Output | Description |
|---|---|
valid |
Whether enough valid crypto approvals were found |
signer |
Comma-separated addresses of valid signers |
approval-count |
Number of unique valid approvals |
canary-balance |
The signer's wallet balance (if canary enabled) |
The signing tool generates a comment like this:
## Crypto-Signed Approval
```crypto-approval
{
"repo": "owner/repo",
"prNumber": 123,
"headSha": "abc123...",
"timestamp": 1710000000
}
```
```signature
<65-byte ECDSA signature in hex>
```The signature covers a serialized message:
CRYPTO_APPROVAL:v1:{repo}:{prNumber}:{headSha}:{timestamp}
| Threat | Mitigation |
|---|---|
| Compromised GitHub account | Attacker can't approve - doesn't have hardware wallet |
| Stolen signing device | Detected via wallet balance drain (canary) |
| Replayed signature | Head SHA binding invalidates after new commits |
| Stale approval | Timestamp expiry (configurable) |
| Insider with admin access | Can't bypass without valid signature |
You have three options for configuring authorized signers:
Create .github/KEYOWNERS:
# Authorized signers for crypto-approval
# One address per line, comments start with #
0xAlice123... # Alice - Security Lead
0xBob456... # Bob - Platform Team
0xCharlie789... # Charlie - CTO
- uses: Jamf-Concepts/crypto-approval-action@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# keyowners-file defaults to .github/KEYOWNERSBenefits:
- Changes tracked in git history with full context
- Document who owns each key with inline comments
- Follows familiar CODEOWNERS pattern
- Transparent and auditable
- uses: Jamf-Concepts/crypto-approval-action@main
with:
allowed-keys: '0xAlice,0xBob,0xCharlie'
github-token: ${{ secrets.GITHUB_TOKEN }}Benefits:
- Simple for small teams
- Visible in workflow file
Trade-off: No inline comments, clutters workflow for many keys
- uses: Jamf-Concepts/crypto-approval-action@main
with:
allowed-keys: ${{ secrets.ALLOWED_SIGNER_KEYS }}
github-token: ${{ secrets.GITHUB_TOKEN }}When to use:
- Organization policy requires secrets for sensitive values
- You don't want signer addresses publicly visible
Trade-off: Loses transparency - no one can independently verify which addresses are authorized or check their canary balances
If multiple sources are configured:
allowed-keysinput takes priority (if provided)- Otherwise, reads from
keyowners-file(defaults to.github/KEYOWNERS)
Require multiple unique signers before a PR can be merged:
allowed-keys: '0xAlice,0xBob,0xCharlie'
min-approvals: '2' # Need 2 of 3 to approveFeatures:
- Each signer can only count once (no duplicate approvals)
- Status shows progress:
1/2 approvals (need 1 more) - All signatures must be valid and unexpired
Every signature is bound to the exact commit SHA of the PR at signing time. This provides critical security:
Signature message: CRYPTO_APPROVAL:v1:{repo}:{prNumber}:{headSha}:{timestamp}
^^^^^^^^
Locked to this commit
What happens when new commits are pushed:
- Alice signs approval for commit
abc123→ ✓ Valid (1/2) - Bob signs approval for commit
abc123→ ✓ Valid (2/2) - PR approved! - Someone pushes a new commit → PR head is now
def456 - Action re-runs automatically (
pull_request: synchronize) - Both signatures now invalid - SHA mismatch
- Status reverts to:
0/2 approvals (need 2 more)
Why this matters:
- Prevents sneaking malicious code in after approval
- All signers must have reviewed the exact code being merged
- Similar to GitHub's "dismiss stale reviews on new commits" but cryptographically enforced
- No one can replay old approvals on new code
If you push changes during the approval process, everyone must re-sign.
The optional canary check queries the signer's wallet balance. If below threshold, the approval is rejected.
Why this matters:
- If an attacker steals your seed phrase, they'll likely drain the wallet
- The drained balance instantly revokes approval rights across all repos
- No manual key rotation needed - it's automatic
- You have financial incentive to protect your keys (skin in the game)
-
Get an RPC API key from a provider like Ankr, Alchemy, or Infura
-
Add a repository secret:
- Go to Settings → Secrets and variables → Actions
- Create secret:
CANARY_RPC_URL - Value:
https://rpc.ankr.com/eth/YOUR_API_KEY(or your provider's URL)
-
Enable in your workflow:
require-canary: 'true'
canary-rpc-url: ${{ secrets.CANARY_RPC_URL }}
canary-min-balance: '10000000000000000' # 0.01 ETH in wei
canary-chain: 'ethereum' # fallback only - see note belowThe canary-rpc-url input takes priority for all RPC calls. If set, it is used directly regardless of canary-chain.
The canary-chain input (ethereum, base, or polygon) is only used as a fallback when canary-rpc-url is not provided. It maps to a default public RPC endpoint:
| Chain | Fallback RPC URL |
|---|---|
ethereum |
https://rpc.ankr.com/eth |
base |
https://rpc.ankr.com/base |
polygon |
https://rpc.ankr.com/polygon |
Recommendation: Always set CANARY_RPC_URL as a repository secret with an authenticated RPC endpoint (from Ankr, Alchemy, or Infura). Public RPCs without API keys are unreliable for production use.
# Install dependencies
npm install
# Run tests
npm test
# Build
npm run bundle
# Run all checks
npm run allSignature expired: The default max age is 3600 seconds (1 hour). If the action rejects a valid signature, check that system clocks are synchronized between the signing device and GitHub. Increase signature-max-age if needed.
Signature not matched to PR: New commits pushed after signing will invalidate all existing signatures (by design — the signature is bound to the head SHA). All signers must re-sign after each new commit.
Signer not recognized: Verify the signing Ethereum address is listed in .github/KEYOWNERS or the allowed-keys input. Addresses are compared case-insensitively.
Canary check failing: Verify the CANARY_RPC_URL secret is set correctly. Public fallback RPCs may be rate-limited under load. Test your RPC URL with curl -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' YOUR_RPC_URL.
Missing statuses: write permission: The workflow must include statuses: write in its permissions block, or the action will fail to set the commit status check.
Action logs: All verification steps are logged in the GitHub Actions run. Check the verify-approval job output for detailed per-comment processing and error messages.
If you have questions, encounter bugs, or want to request a feature, please open an issue on GitHub.
This action uses the following open-source libraries:
| Package | License | Link |
|---|---|---|
| @actions/core | MIT | LICENSE |
| @actions/github | MIT | LICENSE |
| @noble/curves | MIT | LICENSE |
| @noble/hashes | MIT | LICENSE |
| ethers | MIT | LICENSE |
| viem | MIT | LICENSE |
MIT — Copyright 2026, Jamf Software LLC
For privacy information, see Jamf's Privacy Policy.