Skip to content

JWT Verification

This page walks through the verification chain that RIK.register() runs against a GitHub Actions OIDC JWT, in order, with the exact Solidity.

If any check fails, the transaction reverts and no token is minted.

Inputs

register() receives:

ArgumentSource
kidkeccak256(utf8(jwt.header.kid))
headerB64The first JWT segment, as base64url bytes (not decoded).
payloadB64The second JWT segment, as base64url bytes (not decoded).
signatureThe third JWT segment, base64url-decoded.
repoIdThe expected GitHub repository_id.
githubOwnerIdThe expected GitHub repository_owner_id.

The CLI builds all of these from the OIDC token it receives from GitHub.

1. Verify the signature is GitHub's

solidity
RSAKey memory k = _keys[kid];
if (!k.active) revert UnknownKid(kid);

bytes memory signingInput = bytes.concat(headerB64, ".", payloadB64);
bytes32 digest = sha256(signingInput);
if (!RSA.pkcs1Sha256(digest, signature, k.exponent, k.modulus)) revert BadJwt();
  • The kid must map to a key the contract owner has registered and not revoked.
  • The signing input is exactly what GitHub signed: headerB64 + "." + payloadB64. We never decode the JWT just to re-encode it; the bytes the user supplied are the bytes the signature is over.
  • Verification is RSA-PKCS#1 v1.5 over SHA-256 via OpenZeppelin's audited RSA.pkcs1Sha256 (~90k gas).

If this passes, the contract knows the bytes of payloadB64 are authentic.

2. Decode the payload exactly once

solidity
bytes memory payload = bytes(Base64.decode(string(payloadB64)));

The decoded payload is JSON, e.g. {"iss":"https://...","repository_id":"871234567",...}. We do not parse it as JSON. We do byte-level substring searches for each expected claim, against the bytes the signature has already authenticated.

This is what JsonClaim does.

3. aud binds the proof to msg.sender

solidity
JsonClaim.requireStringClaim(
  payload,
  "aud",
  Strings.toHexString(uint160(msg.sender), 20)
);

The token's aud claim must equal the caller's lowercase hex address.

This is the single line that turns a generic GitHub-signed claim into a proof bound to this specific Ethereum address. The CLI sets audience=<lower(address)> when requesting the OIDC token from GitHub; the contract enforces it on the way in.

4. repository_id matches the argument

solidity
JsonClaim.requireStringClaim(
  payload,
  "repository_id",
  Strings.toString(repoId)
);

The caller cannot claim a different repository than the one GitHub attested.

5. repository_owner_id matches the argument

solidity
JsonClaim.requireStringClaim(
  payload,
  "repository_owner_id",
  Strings.toString(uint256(githubOwnerId))
);

The owner ID stored in the Repo struct must come from GitHub's signed claims, not just be self-reported.

6. The issuer is GitHub Actions OIDC

solidity
JsonClaim.requireStringClaim(payload, "iss", EXPECTED_ISS);
// EXPECTED_ISS == "https://token.actions.githubusercontent.com"

We pin the issuer string. If GitHub ever changes the URL (extremely unlikely), the constant has to change, today it's constant to make that a deliberate decision.

7. exp is in the future, nbf is in the past

solidity
(uint256 exp_, bool fe) = _readUintClaim(payload, "exp");
(uint256 nbf_, bool fn) = _readUintClaim(payload, "nbf");
if (!fe || !fn) revert JsonClaim.ClaimMissing("exp/nbf");

if (block.timestamp > exp_) revert("token expired");
if (block.timestamp < nbf_) revert("token not yet valid");

_readUintClaim does a small forward scan over decimal digits starting after the claim marker. It is intentionally tiny.

GitHub Actions OIDC tokens are valid for ~15 minutes. Chain timestamps and runner timestamps can drift by seconds; we accept that drift because there is no cleaner way to bound it on-chain.

8. Repo hasn't already been registered

solidity
if (_ownerOf(repoId) != address(0)) revert AlreadyRegistered(repoId);

RIK is one-per-repo, forever.

9. Record + mint

If all checks pass:

solidity
Repo memory r = Repo({
    githubRepoId: uint64(repoId),
    githubOwnerId: githubOwnerId,
    registeredAt: uint64(block.timestamp),
    registrant: msg.sender
});
_repos[repoId] = r;

emit RepoRegistered(repoId, msg.sender, githubOwnerId, r.registeredAt);

_mint(msg.sender, repoId);

The repoId is truncated to uint64 in storage (the GitHub repository_id is well within uint64); the full uint256 is still used as the ERC-721 tokenId.

Why we don't parse JSON properly

A real JSON parser in Solidity is expensive and easy to get wrong. We don't need one. The signature has already authenticated every byte of payloadB64, so a substring match against an expected literal is sufficient to assert a claim's presence and value.

The downside is that JsonClaim.requireStringClaim only checks string-valued claims. For numeric claims (exp, nbf) we hand-roll a tiny scanner. For everything else we use string equality, which is fine because:

  • The claims we check are all either strings (iss, aud) or stringified numbers (repository_id, repository_owner_id, GitHub emits these as JSON strings, not numbers).
  • The match is byte-equality against bytes whose signature we trust.

Continue

Released under the Apache 2.0 License.