Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 4 additions & 3 deletions bin/git-server
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ from pathlib import Path
from types import ModuleType
from urllib.parse import urlsplit

from scm import Secret
from scm.git.grant import sign_grant
from scm.git.relay import GitRelay
from scm.git.server import GitServer
Expand Down Expand Up @@ -220,7 +221,7 @@ def _require(**values: str | None) -> None:

def make_git_server(
backend: Backend,
secret: str,
secret: Secret,
ca_bundle: str | None,
get_token: GetToken | None = None,
repository: Repository | None = None,
Expand Down Expand Up @@ -250,7 +251,7 @@ def make_git_server(
)


def grant_for(secret: str, operation: GitOperation, repository_id: RepositoryId) -> str:
def grant_for(secret: Secret, operation: GitOperation, repository_id: RepositoryId) -> str:
grant = Grant(
organization_id=ORGANIZATION_ID,
referrer=REFERRER,
Expand Down Expand Up @@ -564,7 +565,7 @@ def main() -> None:
creds = load_credentials()
args.repository = resolve("SCM_GIT_REPOSITORY", args.repository, creds)
# The server prints grants for its repository, so nothing else needs to know a secret it makes up.
args.secret = resolve("SCM_GIT_GRANT_SECRET", args.secret, creds) or secrets.token_hex(32)
args.secret = Secret(resolve("SCM_GIT_GRANT_SECRET", args.secret, creds) or secrets.token_hex(32))
args.ca_bundle = resolve("SCM_GIT_CA_BUNDLE", args.ca_bundle, creds)
_require(SCM_GIT_REPOSITORY=args.repository)
path = args.repository.strip("/").removesuffix(".git")
Expand Down
3 changes: 2 additions & 1 deletion bin/github-client
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import pathlib
import sys
from datetime import date

from scm import Secret
from scm.manager import SourceCodeManager
from scm.types import (
CheckRunOutput,
Expand Down Expand Up @@ -522,7 +523,7 @@ def main() -> None:
args.org_id,
("github", args.repo),
base_url=args.base_url if args.base_url else "http://127.0.0.1:8080",
signing_secret=args.secret if args.secret else "secret",
signing_secret=Secret(args.secret if args.secret else "secret"),
)

if args.command == "get-pull-request":
Expand Down
3 changes: 2 additions & 1 deletion bin/github-server
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ from typing import Any
import jwt
import requests

from scm import Secret
from scm.providers.github.provider import GitHubProvider
from scm.rpc.server import RpcServer
from scm.types import CredentialsSet, Provider, Repository, RepositoryId
Expand Down Expand Up @@ -298,7 +299,7 @@ def main():
app_id = resolve("GITHUB_APP_ID", args.app_id, creds)
private_key_path = resolve("GITHUB_PRIVATE_KEY_PATH", args.private_key, creds)
installation_id = resolve("GITHUB_INSTALLATION_ID", args.installation_id, creds)
secret = resolve("SCM_RPC_SIGNING_SECRET", args.secret, creds) or "secret"
secret = Secret(resolve("SCM_RPC_SIGNING_SECRET", args.secret, creds) or "secret")
base_url = resolve("GITHUB_BASE_URL", args.base_url, creds) or GITHUB_API_BASE
web_base_url = resolve("GITHUB_WEB_BASE_URL", args.web_base_url, creds) or _derive_web_base_url(base_url)
provider_name = resolve("GITHUB_PROVIDER_NAME", args.provider_name, creds) or _derive_provider_name(base_url)
Expand Down
3 changes: 2 additions & 1 deletion bin/gitlab-client
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import pathlib
import sys
from datetime import date

from scm import Secret
from scm.manager import SourceCodeManager
from scm.types import (
CheckRunOutput,
Expand Down Expand Up @@ -415,7 +416,7 @@ def main():

kwargs = {
"base_url": args.base_url if args.base_url else "http://127.0.0.1:8080",
"signing_secret": args.secret if args.secret else "secret",
"signing_secret": Secret(args.secret if args.secret else "secret"),
}

scm = SourceCodeManager.make_proxy_client(
Expand Down
3 changes: 2 additions & 1 deletion bin/gitlab-server
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ from urllib.parse import urlparse

import requests

from scm import Secret
from scm.providers.gitlab.provider import API_VERSION, GitLabProvider
from scm.rpc.server import RpcServer
from scm.types import CredentialsSet, Provider, Repository, RepositoryId
Expand Down Expand Up @@ -192,7 +193,7 @@ def main():

access_token = resolve("GITLAB_ACCESS_TOKEN", args.access_token, creds)
base_url = resolve("GITLAB_BASE_URL", args.base_url, creds) or "https://gitlab.com"
secret = resolve("SCM_RPC_SIGNING_SECRET", args.secret, creds) or "secret"
secret = Secret(resolve("SCM_RPC_SIGNING_SECRET", args.secret, creds) or "secret")

if not access_token:
logger.error("Error: missing required config: GITLAB_ACCESS_TOKEN")
Expand Down
3 changes: 3 additions & 0 deletions src/scm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from scm.secret import Secret

__all__ = ("Secret",)
14 changes: 8 additions & 6 deletions src/scm/git/grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,31 @@

import hashlib
import time
from collections.abc import Sequence
from typing import Any

import jwt
import msgspec

from scm.errors import GitGrantExpired, GitInvalidGrant
from scm.git.types import GRANT_MAX_LENGTH, Grant
from scm.secret import Secret

GRANT_AUDIENCE = "scm-git-grant"
_ALGORITHM = "HS256"
# 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:
def sign_grant(secret: Secret, 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)})
token = jwt.encode(claims, secret.reveal(), 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(
Expand All @@ -41,7 +43,7 @@ def sign_grant(secret: str, grant: Grant, *, now: float | None = None) -> str:
return token


def verify_grant(secrets: list[str], token: str, *, now: float | None = None) -> Grant:
def verify_grant(secrets: Sequence[Secret], token: str, *, now: float | None = None) -> Grant:
"""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
Expand All @@ -64,7 +66,7 @@ def verify_grant(secrets: list[str], token: str, *, now: float | None = None) ->
try:
claims = jwt.decode(
token,
secret,
secret.reveal(),
algorithms=[_ALGORITHM],
audience=GRANT_AUDIENCE,
# exp is checked below against an injectable clock, so tests can pin it. PyJWT checks iat, which it
Expand Down Expand Up @@ -97,6 +99,6 @@ def verify_grant(secrets: list[str], token: str, *, now: float | None = None) ->
return grant


def _key_id(secret: str) -> str:
def _key_id(secret: Secret) -> 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]
return hashlib.sha256(secret.reveal().encode()).hexdigest()[:8]
26 changes: 20 additions & 6 deletions src/scm/git/server.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from urllib.parse import urlsplit

from requests.structures import CaseInsensitiveDict

from scm.errors import ErrorCode, GitProviderNotSupported, GitTokenRejected, GitTokenUnavailable, SCMCodedError
from scm.git.grant import verify_grant
from scm.git.relay import GitRelay, ResponseBody, authorize_request
from scm.git.types import GRANT_HEADER, GetToken, Readable
from scm.helpers import initialize_provider
from scm.rpc.types import StreamResponse
from scm.secret import Secret, require_secrets
from scm.types import GetGitRemoteProtocol, Provider, Repository, RepositoryId

# The status of each refusal GitServer answers itself. None is a 401: on a 401 git asks the user for credentials,
Expand Down Expand Up @@ -75,7 +74,7 @@ class GitServer:

def __init__(
self,
secrets: list[str],
secrets: Sequence[Secret],
fetch_repository: Callable[[int, RepositoryId], Repository | None],
fetch_provider: Callable[[int, Repository], Provider | None],
get_token: GetToken,
Expand All @@ -86,7 +85,7 @@ def __init__(
# Fail when the server is built rather than on every request, because no grant could ever verify.
if not secrets or not all(secrets):
raise ValueError("GitServer needs at least one secret, and none may be empty.")
self.secrets = secrets
self.secrets = require_secrets(secrets, parameter="secrets")
self.fetch_repository = fetch_repository
self.fetch_provider = fetch_provider
self.get_token = get_token
Expand All @@ -112,11 +111,14 @@ def handle(
body as empty, and git sends a push over about 1 MiB chunked, so a Django view needs to pass a stream that
has the whole body.
"""
# Rebound without the grant, so it shows as [Filtered] in the frames of a reported error rather than in
# ``headers``, here and in the relay, which never sends it upstream anyway.
grant_token, headers = _take_grant(headers)
counted_body = _CountingReader(body) if body is not None else None
context = {"repository_id": str(repository_id)}
referrer_tag: dict[str, str] = {}
try:
grant = verify_grant(self.secrets, CaseInsensitiveDict(headers).get(GRANT_HEADER, ""))
grant = verify_grant(self.secrets, grant_token.reveal())
context["organization_id"] = str(grant.organization_id)
referrer_tag["referrer"] = context["referrer"] = grant.referrer
operation = authorize_request(grant, repository_id, method, route, query)
Expand Down Expand Up @@ -181,6 +183,18 @@ def finish(failure: str | None, bytes_sent: int) -> None:
)


def _take_grant(headers: Mapping[str, str]) -> tuple[Secret, dict[str, str]]:
"""Split the grant, empty if there is none, from the other headers. Header names are case-insensitive."""
grant = Secret("")
rest: dict[str, str] = {}
for name, value in headers.items():
if name.lower() == GRANT_HEADER.lower():
grant = Secret(value)
else:
rest[name] = value
return grant, rest


class _CountingReader:
"""The client's request body, counting the bytes the relay reads from it."""

Expand Down
3 changes: 2 additions & 1 deletion src/scm/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from scm.rpc.client import fetch_provider as fetch_proxy_provider
from scm.rpc.client import fetch_repository as fetch_proxy_repository
from scm.secret import Secret
from scm.types import Provider, Referrer, Repository, RepositoryId


Expand Down Expand Up @@ -44,7 +45,7 @@ def make_proxy_client(
*,
referrer: Referrer = "shared",
base_url: str,
signing_secret: str,
signing_secret: Secret,
record_count: Callable[[str, int, dict[str, str]], None] = lambda name, value, tags: None,
retry: RetryConfig | None = None,
):
Expand Down
5 changes: 3 additions & 2 deletions src/scm/rpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from scm.providers.gitlab.provider import GitLabProvider
from scm.rpc.helpers import deserialize_repository, sign_get, sign_post
from scm.rpc.types import ActionAttributes, ActionRequest, ErrorResponse
from scm.secret import Secret
from scm.types import ApiClient, CredentialsSet, Provider, Repository, RepositoryId

SCM_API_URL = "{base_url}/api/0/internal/scm-rpc/"
Expand Down Expand Up @@ -73,7 +74,7 @@ def post(self, url: str, data: bytes, headers: dict[str, str], stream: bool = Fa

def fetch_repository(
url: str,
signing_secret: str,
signing_secret: Secret,
organization_id: int,
repository_id: RepositoryId,
session: Callable[[], Session] = lambda: RequestsSession(),
Expand Down Expand Up @@ -154,7 +155,7 @@ class RpcApiClient(ApiClient):
def __init__(
self,
full_url: str,
signing_secret: str,
signing_secret: Secret,
organization_id: int,
referrer: str,
repository_id: RepositoryId,
Expand Down
18 changes: 11 additions & 7 deletions src/scm/rpc/helpers.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,39 @@
import hashlib
import hmac
from collections.abc import Sequence
from typing import Literal

import msgspec

from scm.errors import RepositoryCouldNotBeDeserialized
from scm.rpc.types import JsonApiData, JsonApiPayload, RepositoryAttributes
from scm.secret import Secret
from scm.types import Repository, RepositoryId


def sign_get(secret: str, organization_id: int, repository_id: RepositoryId) -> str:
def sign_get(secret: Secret, organization_id: int, repository_id: RepositoryId) -> str:
return sign(secret, f"{organization_id}{repository_id}".encode())


def sign_post(secret: str, serialized_body: bytes) -> str:
def sign_post(secret: Secret, serialized_body: bytes) -> str:
return sign(secret, serialized_body)


def sign(secret: str, message: bytes) -> str:
return f"rpc0:{hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()}"
def sign(secret: Secret, message: bytes) -> str:
return f"rpc0:{hmac.new(secret.reveal().encode(), message, hashlib.sha256).hexdigest()}"


def verify_get(secrets: list[str], organization_id: int, repository_id: RepositoryId, received_signature: str) -> bool:
def verify_get(
secrets: Sequence[Secret], organization_id: int, repository_id: RepositoryId, received_signature: str
) -> bool:
return verify(secrets, f"{organization_id}{repository_id}".encode(), received_signature)


def verify_post(secrets: list[str], serialized_body: bytes, received_signature: str) -> bool:
def verify_post(secrets: Sequence[Secret], serialized_body: bytes, received_signature: str) -> bool:
return verify(secrets, serialized_body, received_signature)


def verify(secrets: list[str], message: bytes, received_signature: str) -> bool:
def verify(secrets: Sequence[Secret], message: bytes, received_signature: str) -> bool:
return any(hmac.compare_digest(received_signature, sign(secret, message)) for secret in secrets)


Expand Down
7 changes: 4 additions & 3 deletions src/scm/rpc/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections.abc import Callable, Iterator, Mapping, MutableMapping
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
from urllib.parse import urlparse

import msgspec
Expand All @@ -17,6 +17,7 @@
from scm.rpc.errors import serialize_error
from scm.rpc.helpers import serialize_repository, verify_get, verify_post
from scm.rpc.types import ActionRequest, Response, StreamResponse
from scm.secret import Secret, require_secrets
from scm.types import Provider, Repository, RepositoryId

TEN_MEGABYTES = 10 * 1024 * 1024
Expand All @@ -26,13 +27,13 @@
class RpcServer:
def __init__(
self,
secrets: list[str],
secrets: Sequence[Secret],
fetch_repository: Callable[[int, RepositoryId], Repository | None],
fetch_provider: Callable[[int, Repository], Provider | None],
record_count: Callable[[str, int, dict[str, str]], None],
emit_error: Callable[[Exception], None],
) -> None:
self.secrets = secrets
self.secrets = require_secrets(secrets, parameter="secrets")
self.fetch_repository = fetch_repository
self.fetch_provider = fetch_provider
self.record_count = record_count
Expand Down
Loading
Loading