Skip to content

Commit 545dd6b

Browse files
authored
M4-A5: real GitHub OIDC RS256 time proof bound to two live source digests (#320)
* feat(m4): verify GitHub RS256 OIDC time and two independent-source digest audiences * build(m4): pin OIDC signature dependencies for runtime and tests * feat(m4): bind actual OpenFoot/MatchTV observations to GitHub-issued signed UTC * feat(m4): owner-only GitHub OIDC cryptographic timestamp gate (no DB creds) * test(m4): cryptographic JWT, audience, issuer, owner, clock and workflow negatives * test(m4): guard owner-only GitHub OIDC cryptographic gate * docs(m4): truthful OIDC signed-time evidence scope and non-claims * style(m4): normalize OIDC verifier imports under Ruff * style(m4): normalize OIDC verifier imports under Ruff
1 parent 0a18ff9 commit 545dd6b

7 files changed

Lines changed: 481 additions & 6 deletions

File tree

‎.github/workflows/m4-future-rpl-source-observation.yml‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
github.event.comment.author_association == 'OWNER' &&
3131
github.event.comment.body == '/m4-future-rpl-source-readonly')
3232
)
33+
permissions: {contents: read, id-token: write}
3334
runs-on: ubuntu-latest
3435
timeout-minutes: 6
3536
environment: m1-source-import
@@ -38,7 +39,9 @@ jobs:
3839
with: {persist-credentials: false}
3940
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 pinned
4041
with: {python-version: '3.13'}
41-
- name: Read-only three-day source and live independent calendar check, no database
42+
- name: Install pinned GitHub OIDC cryptographic verifier
43+
run: python -m pip install -e './ml[oidc]'
44+
- name: Read-only source, independent calendar and GitHub signed-time checks
4245
shell: bash
4346
env:
4447
OPENFOOT_API_KEY: ${{ secrets.OPENFOOT_API_KEY }}
@@ -62,7 +65,7 @@ jobs:
6265
from datetime import datetime, timezone
6366
from pathlib import Path
6467
raw = Path(sys.argv[1]).read_bytes()
65-
if not 300 <= len(raw) <= 1500:
68+
if not 400 <= len(raw) <= 2000:
6669
raise SystemExit('FAIL: unbounded future source receipt')
6770
row = json.loads(raw)
6871
fields = {'source','classification','observation_completed_utc',
@@ -73,17 +76,36 @@ jobs:
7376
'live_independent_calendar_observed_utc',
7477
'live_independent_calendar_sha256',
7578
'live_independent_calendar_verified',
76-
'live_independent_calendar_source'}
79+
'live_independent_calendar_source',
80+
'github_oidc_time_verified','github_oidc_issuer',
81+
'source_oidc_issued_utc','kickoff_oidc_issued_utc',
82+
'source_oidc_jwt_sha256','kickoff_oidc_jwt_sha256',
83+
'immutable_pg_capture'}
7784
now = datetime.fromisoformat(row['observation_completed_utc'].replace(
7885
'Z', '+00:00'))
7986
corroborated = datetime.fromisoformat(
8087
row['live_independent_calendar_observed_utc'].replace('Z', '+00:00'))
88+
token1 = datetime.fromisoformat(
89+
row['source_oidc_issued_utc'].replace('Z', '+00:00'))
90+
token2 = datetime.fromisoformat(
91+
row['kickoff_oidc_issued_utc'].replace('Z', '+00:00'))
92+
from datetime import timedelta
8193
if (set(row)!=fields or row['source'] !=
8294
'OpenFoot Starter (private source only)' or row['classification'] !=
8395
'UNAPPROVED_PRIVATE_FUTURE_SOURCE_OBSERVATION' or
8496
row['days']!=3 or row['fixtures']!=8 or row['club_identities']!=16 or
8597
row['live_independent_calendar_verified'] is not True or
8698
row['live_independent_calendar_source'] != 'MATCHTV_LIVE' or
99+
row['github_oidc_time_verified'] is not True or
100+
row['github_oidc_issuer'] !=
101+
'https://token.actions.githubusercontent.com' or
102+
row['immutable_pg_capture'] is not False or
103+
not re.fullmatch(r'[a-f0-9]{64}',row['source_oidc_jwt_sha256']) or
104+
not re.fullmatch(r'[a-f0-9]{64}',row['kickoff_oidc_jwt_sha256']) or
105+
row['source_oidc_jwt_sha256']==row['kickoff_oidc_jwt_sha256'] or
106+
token1.tzinfo != timezone.utc or token2.tzinfo != timezone.utc or
107+
not corroborated-timedelta(seconds=2) <= token1 <= token2 <
108+
datetime(2026,11,20,16,15,tzinfo=timezone.utc) or
87109
not re.fullmatch(r'[0-9a-f]{64}',
88110
row['live_independent_calendar_sha256']) or
89111
corroborated.tzinfo != timezone.utc or
@@ -103,6 +125,9 @@ jobs:
103125
print('M4_SOURCE_ENVELOPES_SHA256='+row['source_envelopes_sha256'])
104126
print('M4_INDEPENDENT_CALENDAR_SHA256='+
105127
row['live_independent_calendar_sha256'])
128+
print('M4_GITHUB_OIDC_SOURCE_PROOF_SHA256='+row['source_oidc_jwt_sha256'])
129+
print('M4_GITHUB_OIDC_KICKOFF_PROOF_SHA256='+
130+
row['kickoff_oidc_jwt_sha256'])
106131
with open(__import__('os').environ['GITHUB_STEP_SUMMARY'],
107132
'a',encoding='utf-8') as out:
108133
out.write('## M4 private future fixture source probe, not forecasts\n')
@@ -113,6 +138,9 @@ jobs:
113138
out.write('Live independent publisher: MatchTV (not time attestation).\n')
114139
out.write('Independent calendar SHA: `'+
115140
row['live_independent_calendar_sha256']+'`\n')
116-
out.write('Database capture / cryptographic time attestation '
117-
'remain A5 OPEN.\n')
141+
out.write('GitHub OIDC signed digest/time proof: source '+
142+
row['source_oidc_jwt_sha256']+'; independent calendar '+
143+
row['kickoff_oidc_jwt_sha256']+'; same GitHub issuer.\n')
144+
out.write('No publisher-signed original-serving proof. Database capture '
145+
'and forecast remain A5 OPEN.\n')
118146
PY

‎docs/M4_PROSPECTIVE_PROTOCOL.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,31 @@ An exact-owner, protected-main, environment-gated one-off read-only job `.github
5656

5757
**A5 stays OPEN after this first read-only probe** until a separately corroborated source/kickoff/club mapping is captured with verifiable server clocks into the immutable `ff_m4_future_captures` table and an `ff_m4_shadow_entries` FORECAST/ABSTAIN is committed **before kickoff**. The OpenFoot feed alone cannot substitute for independent kickoff corroboration or first-serving proof. Do not backdate anything.
5858

59+
### A5 GitHub-issued signed-time observation (still no database write)
60+
61+
The protected-main owner-only workflow now requests two **distinct-purpose
62+
GitHub Actions OIDC JWTs** after live authenticated OpenFoot and separately
63+
corroborated MatchTV observations. The first token's custom signed audience
64+
commits to the aggregate OpenFoot envelope SHA-256; the second commits to
65+
the independently fetched MatchTV response SHA-256. The runner verifies
66+
RS256 signatures using public keys from GitHub's **fixed** HTTPS OIDC discovery
67+
and JWKS endpoints, checking repository ID/name, owner ID, protected main
68+
ref and commit, workflow path, environment, run ID/attempt, event kind,
69+
issuance/expiration and digest-specific audiences. No untrusted JWT header
70+
can select a verification endpoint. Tamper/claims/time tests are mandatory.
71+
Private JWTs/JWKs stay in protected worker memory; only SHA-256 and the
72+
GitHub-issued second-precision times enter the bounded read-only receipt.
73+
74+
**Limitations:** both JWTs have **the same signing authority, GitHub**;
75+
only the **underlying football sources** are independent. The publishers'
76+
original first-serving times are NOT independently signed. The GitHub tokens
77+
authenticate that the reviewed worker committed to those digests at GitHub's
78+
issuance time, not an original publisher timestamp. There is still **no
79+
write authorization, PostgreSQL server-clock capture, prospective model
80+
FORECAST/ABSTAIN, or public release**. A5 requires a separate immutable
81+
pre-kickoff DB admission with verified signed-time receipts. The 2025/26
82+
consumed holdout stays sealed.
83+
5984
## Initial code boundary
6085

6186
`apps/web/lib/m4-prospective-preflight.mjs` accepts only a tight metadata envelope and an **untrusted caller-supplied evaluation timestamp**. It performs deterministic structural checks, returning `blocked` or `candidate_only`. Even a positive candidate explicitly reports **`genuineAsOfAttested:false`, `modelApproved:false`, `writeAuthorized:false`, `publicationAuthorized:false`**. Digests are format-tested, not authenticated. **No code may treat this function alone as an authorization to run a model, write to the database, claim an authentic pre-match forecast or release predictions.** A3–A5 must establish actual server-trusted attestation, append-only persistence and private execution. This first gate does not use real source data.

‎ml/pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ requires-python = ">=3.11"
1010
dependencies = []
1111

1212
[project.optional-dependencies]
13-
test = ["pytest>=8,<10", "ruff>=0.12,<1"]
13+
test = ["pytest>=8,<10", "ruff>=0.12,<1", "PyJWT[crypto]==2.15.1", "cryptography==50.0.2"]
14+
oidc = ["PyJWT[crypto]==2.15.1", "cryptography==50.0.2"]
1415

1516
[tool.setuptools.packages.find]
1617
where = ["src"]
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""M4-A5 GitHub-issued independent server-time proof of protected source digests.
2+
3+
The GitHub OIDC issuer signs the audience (which binds source bytes hashed by
4+
protected code), job identity, run_id and issuance UTC. These proofs DO NOT
5+
establish the publisher's first-serving timestamp or its own signature. The
6+
source and kickoff publishers are independent; both JWTs have the SAME GitHub
7+
signing authority. An immutable PostgreSQL server-clock commit remains required
8+
before any genuine prospective-as-of claim. Never print JWTs or JWKS contents.
9+
"""
10+
import os
11+
import re
12+
import ssl
13+
from dataclasses import dataclass
14+
from datetime import UTC, datetime
15+
from hashlib import sha256
16+
from json import dumps, loads
17+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
18+
from urllib.request import (
19+
HTTPRedirectHandler,
20+
HTTPSHandler,
21+
Request,
22+
build_opener,
23+
)
24+
25+
import jwt
26+
from jwt.algorithms import RSAAlgorithm
27+
28+
from football_forecast.data.contracts import DataError
29+
30+
ISSUER = "https://token.actions.githubusercontent.com"
31+
DISCOVERY = ISSUER + "/.well-known/openid-configuration"
32+
JWKS = ISSUER + "/.well-known/jwks"
33+
REPO = "StanleyLl0yd/FootballForecast"
34+
REPO_ID = "1422508335"
35+
ACTOR = "StanleyLl0yd"
36+
ACTOR_ID = "131160519"
37+
WORKFLOW = (REPO +
38+
"/.github/workflows/m4-future-rpl-source-observation.yml@refs/heads/main")
39+
ENVIRONMENT = "m1-source-import"
40+
SHA = re.compile(r"^[a-f0-9]{64}$")
41+
COMMIT = re.compile(r"^[a-f0-9]{40}$")
42+
MAX_JWT = 10000
43+
MAX_JWKS = 100000
44+
MAX_PROOF_AGE = 300
45+
46+
47+
class _NoRedirect(HTTPRedirectHandler):
48+
def redirect_request(self, request, fp, code, msg, headers, newurl):
49+
raise DataError("M4 OIDC unexpected redirect")
50+
51+
52+
@dataclass(frozen=True, slots=True)
53+
class ProtectedTimeProof:
54+
"""Private live signatures. Never serialize as public outputs or artifacts."""
55+
source_jwt: str
56+
kickoff_jwt: str
57+
source_jwk_json: str
58+
kickoff_jwk_json: str
59+
source_issued_utc: str
60+
kickoff_issued_utc: str
61+
62+
@property
63+
def source_token_sha256(self):
64+
return sha256(self.source_jwt.encode("ascii")).hexdigest()
65+
66+
@property
67+
def kickoff_token_sha256(self):
68+
return sha256(self.kickoff_jwt.encode("ascii")).hexdigest()
69+
70+
71+
def _deny():
72+
raise DataError("M4: trusted GitHub OIDC time proof refused")
73+
74+
75+
def _https_json(url: str, *, headers=None, limit=MAX_JWKS):
76+
opener = build_opener(_NoRedirect(), HTTPSHandler(context=ssl.create_default_context()))
77+
with opener.open(Request(url, headers=headers or {"Accept": "application/json"},
78+
method="GET"), timeout=10) as response:
79+
if response.status != 200 or "json" not in response.headers.get(
80+
"Content-Type", "").lower():
81+
_deny()
82+
raw = response.read(limit + 1)
83+
if not 2 <= len(raw) <= limit:
84+
_deny()
85+
def pairs(values):
86+
row = {}
87+
for key, value in values:
88+
if key in row:
89+
_deny()
90+
row[key] = value
91+
return row
92+
try:
93+
return loads(raw.decode("utf-8", "strict"), object_pairs_hook=pairs)
94+
except (UnicodeError, ValueError) as exc:
95+
raise DataError("M4: invalid OIDC JSON") from exc
96+
97+
98+
def _keys_from_github() -> tuple[dict, ...]:
99+
metadata = _https_json(DISCOVERY, limit=16000)
100+
if (type(metadata) is not dict or metadata.get("issuer") != ISSUER or
101+
metadata.get("jwks_uri") != JWKS or
102+
"RS256" not in metadata.get("id_token_signing_alg_values_supported", [])):
103+
_deny()
104+
response = _https_json(JWKS)
105+
keys = response.get("keys") if type(response) is dict else None
106+
if type(keys) is not list or not 1 <= len(keys) <= 40:
107+
_deny()
108+
return tuple(keys)
109+
110+
111+
def _expected_identity(env: dict[str, str]):
112+
required = {
113+
"GITHUB_REPOSITORY": REPO,
114+
"GITHUB_REF": "refs/heads/main",
115+
"GITHUB_ACTOR": ACTOR,
116+
}
117+
if any(env.get(k) != v for k, v in required.items()):
118+
_deny()
119+
if (env.get("GITHUB_EVENT_NAME") not in {"issue_comment", "workflow_dispatch"}
120+
or not COMMIT.fullmatch(env.get("GITHUB_SHA", ""))
121+
or not env.get("GITHUB_RUN_ID", "").isdigit()
122+
or not env.get("GITHUB_RUN_ATTEMPT", "").isdigit()):
123+
_deny()
124+
125+
126+
def _claims_time(value):
127+
if type(value) is not int or not 1_000_000_000 <= value <= 4_000_000_000:
128+
_deny()
129+
return value
130+
131+
132+
def verify_github_time_jwt(
133+
token: str, audience: str, jwks: tuple[dict, ...],
134+
env: dict[str, str], observed_at: datetime, verified_at: datetime,
135+
) -> tuple[str, str]:
136+
"""Pure detached-signature validation against already verified issuer keys.
137+
138+
Returns (issuance UTC, canonical public signing JWK). Does not authorize DB
139+
writes by itself. Runtime JWKS MUST come from fixed HTTPS issuer discovery.
140+
"""
141+
_expected_identity(env)
142+
if (type(token) is not str or not 400 <= len(token) <= MAX_JWT or
143+
type(audience) is not str or len(audience) > 256 or
144+
not audience.startswith("ff-m4-2026-27-") or
145+
not isinstance(observed_at, datetime) or observed_at.tzinfo != UTC or
146+
not isinstance(verified_at, datetime) or verified_at.tzinfo != UTC or
147+
type(jwks) is not tuple or not 1 <= len(jwks) <= 40):
148+
_deny()
149+
try:
150+
header = jwt.get_unverified_header(token)
151+
if (header.get("alg") != "RS256" or
152+
type(header.get("kid")) is not str or
153+
not 1 <= len(header["kid"]) <= 256 or
154+
any(k in header for k in ("jku", "x5u", "jwk", "x5c"))):
155+
_deny()
156+
matching = [
157+
key for key in jwks if type(key) is dict and
158+
key.get("kid") == header["kid"] and key.get("kty") == "RSA" and
159+
key.get("use", "sig") == "sig" and
160+
key.get("alg", "RS256") == "RS256" and
161+
type(key.get("n")) is str and type(key.get("e")) is str
162+
]
163+
if len(matching) != 1:
164+
_deny()
165+
public_key = RSAAlgorithm.from_jwk(dumps(matching[0]))
166+
claims = jwt.decode(
167+
token, public_key, algorithms=["RS256"], audience=audience, issuer=ISSUER,
168+
options={"require": [
169+
"exp", "iat", "nbf", "jti", "sub", "aud", "iss",
170+
"repository", "repository_id", "actor", "actor_id",
171+
"ref", "sha", "run_id", "run_attempt", "environment",
172+
"workflow_ref", "event_name",
173+
]},
174+
leeway=5,
175+
)
176+
except (ValueError, TypeError, jwt.PyJWTError) as exc:
177+
raise DataError("M4: invalid cryptographic time signature") from exc
178+
exact = {
179+
"repository": REPO, "repository_id": REPO_ID, "actor": ACTOR,
180+
"actor_id": ACTOR_ID, "ref": "refs/heads/main",
181+
"sha": env["GITHUB_SHA"], "run_id": env["GITHUB_RUN_ID"],
182+
"run_attempt": env["GITHUB_RUN_ATTEMPT"], "environment": ENVIRONMENT,
183+
"workflow_ref": WORKFLOW, "event_name": env["GITHUB_EVENT_NAME"],
184+
"sub": f"repo:{REPO}:environment:{ENVIRONMENT}",
185+
}
186+
if (any(claims.get(k) != v for k, v in exact.items()) or
187+
claims.get("aud") != audience or
188+
type(claims.get("jti")) is not str or
189+
not 12 <= len(claims["jti"]) <= 512):
190+
_deny()
191+
issued = _claims_time(claims.get("iat"))
192+
not_before = _claims_time(claims.get("nbf"))
193+
expires = _claims_time(claims.get("exp"))
194+
observed = observed_at.timestamp()
195+
verified = verified_at.timestamp()
196+
if (not_before > issued + 5 or expires <= issued or expires > issued + 900
197+
or issued + 5 < observed or issued > verified + 5
198+
or verified - issued > MAX_PROOF_AGE):
199+
_deny()
200+
return datetime.fromtimestamp(issued, UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), dumps(
201+
matching[0], sort_keys=True, separators=(",", ":"),
202+
)
203+
204+
205+
def _token_for_audience(audience: str, env: dict[str, str]) -> str:
206+
base = env.get("ACTIONS_ID_TOKEN_REQUEST_URL", "")
207+
token = env.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "")
208+
parsed = urlsplit(base)
209+
if (parsed.scheme != "https" or
210+
parsed.hostname not in {"pipelines.actions.githubusercontent.com",
211+
"token.actions.githubusercontent.com"} or
212+
parsed.username is not None or parsed.password is not None or
213+
parsed.port not in (None, 443) or not token or len(token) > 6000 or
214+
len(base) > 2000):
215+
_deny()
216+
values = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
217+
if any(key == "audience" for key, _ in values):
218+
_deny()
219+
url = urlunsplit((
220+
parsed.scheme, parsed.netloc, parsed.path,
221+
urlencode([*values, ("audience", audience)]), "",
222+
))
223+
doc = _https_json(url, headers={
224+
"Accept": "application/json", "Authorization": "Bearer " + token,
225+
}, limit=16000)
226+
result = doc.get("value") if type(doc) is dict else None
227+
if type(result) is not str:
228+
_deny()
229+
return result
230+
231+
232+
def attest_future_source_digests(
233+
source_sha: str, kickoff_sha: str, source_observed: datetime,
234+
kickoff_observed: datetime, *, env: dict[str, str] | None = None,
235+
) -> ProtectedTimeProof:
236+
"""Both publisher bytes must already have been fetched and checked LIVE."""
237+
if env is None:
238+
env = dict(os.environ)
239+
_expected_identity(env)
240+
if (type(source_sha) is not str or not SHA.fullmatch(source_sha)
241+
or type(kickoff_sha) is not str or not SHA.fullmatch(kickoff_sha)
242+
or source_sha == kickoff_sha
243+
or not isinstance(source_observed, datetime) or
244+
not isinstance(kickoff_observed, datetime) or
245+
source_observed.tzinfo != UTC or kickoff_observed.tzinfo != UTC or
246+
source_observed > kickoff_observed):
247+
_deny()
248+
audience_source = "ff-m4-2026-27-openfoot-sha256-" + source_sha
249+
audience_kickoff = "ff-m4-2026-27-matchtv-sha256-" + kickoff_sha
250+
# Independent provider observations. Two GitHub-signed audiences, not
251+
# two independent OIDC issuers. Never call these third-party-signed.
252+
source_jwt = _token_for_audience(audience_source, env)
253+
kickoff_jwt = _token_for_audience(audience_kickoff, env)
254+
jwks = _keys_from_github()
255+
verified_at = datetime.now(UTC)
256+
a_utc, a_jwk = verify_github_time_jwt(
257+
source_jwt, audience_source, jwks, env, kickoff_observed, verified_at)
258+
b_utc, b_jwk = verify_github_time_jwt(
259+
kickoff_jwt, audience_kickoff, jwks, env, kickoff_observed, verified_at)
260+
if source_jwt == kickoff_jwt or a_utc > b_utc:
261+
_deny()
262+
return ProtectedTimeProof(
263+
source_jwt=source_jwt, kickoff_jwt=kickoff_jwt,
264+
source_jwk_json=a_jwk, kickoff_jwk_json=b_jwk,
265+
source_issued_utc=a_utc, kickoff_issued_utc=b_utc,
266+
)

0 commit comments

Comments
 (0)