Repository navigation
feat(git): Add git grants and a streaming git relay #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a44b8a6
f95a570
1ace97f
c18cabd
35c92d8
a127814
6d2d2bf
f21a127
747d120
026b7c7
bcf1f89
a340f53
a0c232a
754a3fb
c3957a2
0b62f41
54ed248
a6802f0
4711964
88ab8e5
2662dff
f04bef6
20dd6a2
023be64
1b024ab
f593ce2
dd7a9b2
e8292aa
d1db822
4c55d81
4f65294
451e9f8
78833cb
cd8d705
161ba13
7c8dd13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Signing and verifying git grants. | ||
|
|
||
| A grant travels as an HS256 JWT, the format Sentry and Seer already sign viewer contexts with. The grant's fields | ||
| are claims, alongside ``exp``, ``iat`` and an ``aud`` of ``scm-git-grant``. The verifier requires that audience, | ||
| so no other token signed with the same secret, such as a viewer context, passes as a grant. A ``kid`` header | ||
| names the secret that signed the grant, as in Sentry's viewer contexts, so the verifier can hold several secrets | ||
| while they rotate. | ||
| """ | ||
|
|
||
| import hashlib | ||
| import time | ||
| from typing import Any | ||
|
|
||
| import jwt | ||
| import msgspec | ||
|
|
||
| from scm.errors import GitGrantExpired, GitInvalidGrant | ||
| from scm.git.types import GRANT_MAX_LENGTH, Grant | ||
|
|
||
| GRANT_AUDIENCE = "scm-git-grant" | ||
| _ALGORITHM = "HS256" | ||
|
Comment on lines
+20
to
+21
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If these values aren't configurable why bother making them constants? They could be inlined into the function.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inlined the clock skew, which had one use, as PyJWT's 🤖 Addressed by Claude Code |
||
| # Claims the JWT layer owns. Every other claim must be a Grant field. | ||
| _JWT_CLAIMS = frozenset({"aud", "exp", "iat"}) | ||
|
|
||
|
|
||
| def sign_grant(secret: str, grant: Grant, *, now: float | None = None) -> str: | ||
| # HMAC accepts an empty key, so an empty secret, say from an unset setting, would let anyone sign a grant. | ||
| if not secret: | ||
| raise ValueError("A git grant secret is empty.") | ||
| claims: dict[str, Any] = msgspec.to_builtins(grant) | ||
| claims["exp"] = claims.pop("expires_at") | ||
| claims["iat"] = int(time.time() if now is None else now) | ||
| claims["aud"] = GRANT_AUDIENCE | ||
| token = jwt.encode(claims, secret, algorithm=_ALGORITHM, headers={"kid": _key_id(secret)}) | ||
| # Refused here, the signer's tests catch a grant that is too long, rather than the sandbox router in production. | ||
| if len(token) > GRANT_MAX_LENGTH: | ||
| raise ValueError( | ||
| f"The git grant is {len(token)} characters, over GRANT_MAX_LENGTH ({GRANT_MAX_LENGTH}). It travels in a " | ||
| "request header, and sandbox-router refuses a longer one." | ||
| ) | ||
| return token | ||
|
|
||
|
|
||
| def verify_grant(secrets: list[str], token: str, *, now: float | None = None) -> Grant: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason not to require
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I kept it optional. Every production caller wants the wall clock, and making 🤖 Addressed by Claude Code |
||
| """Return the grant if one of ``secrets`` signed it and it has not expired. | ||
|
|
||
| Verification takes a list of secrets, as ``RpcServer`` does, so a new secret can be added before signers | ||
| switch to it and the old one removed after their grants expire. | ||
| """ | ||
| # As in sign_grant: a grant signed with an empty secret proves nothing. | ||
| if not all(secrets): | ||
| raise ValueError("A git grant secret is empty.") | ||
| now = time.time() if now is None else now | ||
| if not token: | ||
| raise GitInvalidGrant(detail="The git grant is missing.") | ||
| try: | ||
| kid = jwt.get_unverified_header(token).get("kid") | ||
| except jwt.DecodeError: | ||
| raise GitInvalidGrant(detail="The git grant is malformed.") from None | ||
| secret = next((secret for secret in secrets if _key_id(secret) == kid), None) | ||
| if secret is None: | ||
| raise GitInvalidGrant(detail="The git grant was not signed with a known secret.") | ||
|
|
||
| try: | ||
| claims = jwt.decode( | ||
| token, | ||
| secret, | ||
| algorithms=[_ALGORITHM], | ||
| audience=GRANT_AUDIENCE, | ||
| # exp is checked below against an injectable clock, so tests can pin it. PyJWT checks iat, which it | ||
| # also requires to be a number. | ||
| options={"require": sorted(_JWT_CLAIMS), "verify_exp": False}, | ||
| # How far in the future iat may be, in seconds. Seer signs grants on other hosts than the one verifying | ||
| # them, and a clock running a little ahead should not get its grants refused. | ||
| leeway=60, | ||
| ) | ||
| except jwt.InvalidSignatureError: | ||
| raise GitInvalidGrant(detail="The git grant's signature does not match.") from None | ||
| except jwt.DecodeError: | ||
| raise GitInvalidGrant(detail="The git grant is malformed.") from None | ||
| except jwt.InvalidTokenError as e: | ||
| # The signature matched or was never reached, and PyJWT's reasons name only claims, not values. | ||
| raise GitInvalidGrant(detail=f"The git grant is invalid: {e}") from None | ||
|
|
||
| fields = {name: value for name, value in claims.items() if name not in _JWT_CLAIMS} | ||
| try: | ||
| grant = msgspec.convert({**fields, "expires_at": claims["exp"]}, type=Grant, strict=True) | ||
| except msgspec.ValidationError as e: | ||
| # The signature matched, so a holder of the secret wrote these claims; telling them why they are invalid | ||
| # gives nothing away. | ||
| raise GitInvalidGrant(detail=f"The git grant's payload is invalid: {e}") from None | ||
|
vaind marked this conversation as resolved.
|
||
|
|
||
| if grant.expires_at <= now: | ||
| raise GitGrantExpired(detail="The git grant has expired.") | ||
| if not grant.repositories: | ||
| raise GitInvalidGrant(detail="The git grant names no repositories.") | ||
| return grant | ||
|
|
||
|
|
||
| def _key_id(secret: str) -> str: | ||
| # The first 8 hex characters of the secret's SHA-256, the same key id Sentry's viewer contexts use. | ||
| return hashlib.sha256(secret.encode()).hexdigest()[:8] | ||
Uh oh!
There was an error while loading. Please reload this page.