-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpush-secrets.py
More file actions
309 lines (264 loc) · 12.4 KB
/
Copy pathpush-secrets.py
File metadata and controls
309 lines (264 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""
BAUER GROUP XPD-RPIImage - push local key material into GitHub Actions secrets.
Release signing runs in CI, so the private signing key has to live in the
repository's Actions secrets. This is the supported way to put it there:
the web UI means pasting a private key into a browser, and
`gh secret set --body` puts it in the process arguments, where it is visible
in /proc and lands in shell history. Everything here goes over stdin.
MAINTENANCE
The SECRETS table below is the only thing to edit - one entry per secret,
giving the secret name, the file and whether it holds a private or public
key. The kind is checked rather than trusted: uploading the .pub by
accident and believing signing is configured would only surface when
every device rejects every release.
Usage:
python scripts/push-secrets.py --dry-run # what would be pushed
python scripts/push-secrets.py # do it
python scripts/push-secrets.py --list # what is configured now
python scripts/push-secrets.py --verify # local key vs committed public key
VERIFYING A SECRET AFTER THE FACT
A private key cannot be read back out of Actions secrets, so the public
half is committed to the image tree and every push prints a fingerprint
derived from it. Comparing that fingerprint against the committed key is
how "is CI signing with the key our devices trust" stays answerable -
which is exactly what --verify does, without needing credentials.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from cryptography.hazmat.primitives import serialization
from rich import box
from rich.console import Console
from rich.panel import Panel
except ImportError:
print("error: missing dependencies. run: pip install -r scripts/requirements.txt",
file=sys.stderr)
raise SystemExit(2)
console = Console()
ROOT = Path(__file__).resolve().parent.parent
@dataclass(frozen=True)
class Secret:
name: str
#: the private half - local only, never committed
path: str
#: the public counterpart that IS committed and ships in the image. This
#: is what makes the secret verifiable: the private key becomes invisible
#: the moment it is uploaded, so the only way to answer "is CI signing
#: with the key our devices trust" is to compare against a public half
#: that lives in the repository, under review, in git history.
public: str = ""
private: bool = True
# ---------------------------------------------------------------------------
# The table
# ---------------------------------------------------------------------------
# BGRPIIMAGE_SIGNING_KEY is what the release workflow signs bundle manifests
# with; devices verify against the matching public key shipped in the image.
#
# A second keypair whose private half is kept genuinely OFFLINE - the one that
# lets trust be re-established if this one is ever compromised - does not
# belong here at all. Only its public half goes into the image tree, because a
# key held in CI cannot be the recovery for a compromise of CI.
SECRETS: list[Secret] = [
Secret(
"BGRPIIMAGE_SIGNING_KEY",
".secrets/bgrpiimage-recovery.key",
public="src/modules/bgrpiimage-base/filesystem/root"
"/usr/share/bgrpiimage/trusted-keys.d/bgrpiimage-recovery.pub",
),
]
def error(title: str, body: str, hint: str | None = None) -> None:
text = body
if hint:
text += f"\n\n[dim]hint:[/] {hint}"
console.print(Panel(text, title=f"[red]{title}[/]", border_style="red", box=box.ROUNDED))
def run_gh(args: list[str], stdin: bytes | None = None) -> subprocess.CompletedProcess[bytes]:
return subprocess.run( # noqa: S603 - fixed argv, no shell
["gh", *args], input=stdin, capture_output=True, check=False
)
def fingerprint(pem: bytes, private: bool) -> str:
"""Identify a key without revealing it.
SHA-256 over the DER SubjectPublicKeyInfo, which is the same value whether
it is derived from the private half or read from the public one. That is
the point: print it when pushing, compare it later against the .pub that
ships in the image, and "is CI signing with the right key" stays
answerable without touching the private half again.
"""
if private:
key = serialization.load_pem_private_key(pem, password=None)
pub = key.public_key()
else:
pub = serialization.load_pem_public_key(pem)
der = pub.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return base64.b64encode(hashlib.sha256(der).digest()).decode()[:32]
def load(secret: Secret) -> tuple[bytes, str, bool]:
"""Read and validate one entry. Returns (normalised pem, fingerprint, had_crlf)."""
path = ROOT / secret.path
if not path.is_file():
error(
"key file missing",
f"{secret.name}: no such file: {secret.path}",
"generate it with:\n"
f" openssl genpkey -algorithm ed25519 -out {secret.path}\n"
# as_posix(): the hint is a command to copy and paste, and a
# Windows separator would not survive that.
f" openssl pkey -in {secret.path} -pubout -out "
f"{Path(secret.path).with_suffix('.pub').as_posix()}",
)
raise SystemExit(1)
raw = path.read_bytes()
had_crlf = b"\r\n" in raw
# The runner consumes this on Linux; these keys are generated on Windows
# and carry CRLF, which would reach openssl verbatim.
pem = raw.replace(b"\r\n", b"\n")
# Decide on the PEM header rather than on whether a loader accepts it:
# some builds happily read a public PEM through a private-key path, so
# parseability does not tell the two halves apart.
if secret.private and b"BEGIN PUBLIC KEY" in pem:
error(
"public key in a private slot",
f"{secret.name}: {secret.path} contains a PUBLIC key.",
"the public half never goes into a secret - it belongs in the image tree",
)
raise SystemExit(1)
if not secret.private and b"PRIVATE KEY" in pem:
error("private key in a public slot", f"{secret.name}: {secret.path} is a PRIVATE key.")
raise SystemExit(1)
try:
fp = fingerprint(pem, secret.private)
except Exception as exc: # noqa: BLE001 - any parse failure is the same answer
error("unreadable key", f"{secret.name}: {secret.path} could not be parsed.\n{exc}")
raise SystemExit(1) from exc
check_pair(secret, fp)
return pem, fp, had_crlf
def check_pair(secret: Secret, fp: str) -> None:
"""Refuse to upload a key the devices would not trust.
Once a private key is in Actions secrets it cannot be read back, so a
mismatch between what CI signs with and what the image trusts is
undetectable from the outside - it surfaces as every device rejecting
every release, with nothing to inspect. Catching it here costs one
comparison and is the only moment where both halves are in reach.
"""
if not secret.public:
return
pub_path = ROOT / secret.public
if not pub_path.is_file():
error(
"public counterpart missing",
f"{secret.name}: {secret.public} does not exist.",
"the private key would be unverifiable - commit the matching public "
"key so devices, and this check, have something to compare against:\n"
f" openssl pkey -in {secret.path} -pubout -out {secret.public}",
)
raise SystemExit(1)
try:
pub_fp = fingerprint(pub_path.read_bytes().replace(b"\r\n", b"\n"), private=False)
except Exception as exc: # noqa: BLE001
error("unreadable public key", f"{secret.name}: {secret.public}\n{exc}")
raise SystemExit(1) from exc
if pub_fp != fp:
error(
"key pair mismatch",
f"{secret.name}: the private key and the committed public key are "
f"different keys.\n\n"
f" private ({secret.path})\n {fp}\n"
f" public ({secret.public})\n {pub_fp}",
"uploading this would make CI sign with a key no device trusts, and "
"the failure would only appear when every device rejects every "
"release. Regenerate the public half from the private one:\n"
f" openssl pkey -in {secret.path} -pubout -out {secret.public}",
)
raise SystemExit(1)
def resolve_repo(explicit: str | None) -> str:
if explicit:
return explicit
proc = run_gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])
if proc.returncode != 0:
error(
"repository unknown",
"could not determine the repository from this checkout.",
"pass --repo OWNER/NAME",
)
raise SystemExit(1)
return proc.stdout.decode().strip()
def preflight() -> None:
if shutil.which("gh") is None:
error("gh not found", "the GitHub CLI is required.", "https://cli.github.com")
raise SystemExit(1)
if run_gh(["auth", "status"]).returncode != 0:
error("not authenticated", "gh is not logged in.", "run: gh auth login")
raise SystemExit(1)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-n", "--dry-run", action="store_true", help="report without sending")
ap.add_argument("-l", "--list", action="store_true", help="list secrets currently set")
ap.add_argument("-c", "--verify", action="store_true",
help="check the local keys against the committed public ones and stop")
ap.add_argument("--repo", help="OWNER/NAME (default: this checkout's remote)")
args = ap.parse_args()
# --verify touches nothing remote, so it must not require gh or a login:
# it is the check a reviewer runs, and the one CI could run on a pull
# request, neither of which has credentials for this repository.
if args.verify:
for secret in SECRETS:
_pem, fp, _crlf = load(secret)
console.print(f"[green]+[/] [cyan]{secret.name}[/]")
console.print(f" private {secret.path}")
console.print(f" public {secret.public or '[dim]none declared[/]'}")
console.print(f" fingerprint {fp}")
console.print("[green]local keys match their committed public halves[/]")
return 0
preflight()
repo = resolve_repo(args.repo)
if args.list:
console.print(f"[cyan]secrets on[/] {repo}")
proc = run_gh(["secret", "list", "--repo", repo])
sys.stdout.write(proc.stdout.decode())
return proc.returncode
# One line per secret rather than a table: a fingerprint that gets
# ellipsised to fit a narrow terminal identifies nothing, which is the one
# thing this output exists to do.
def report(secret: Secret, fp: str, had_crlf: bool, done: bool) -> None:
mark = "[green]+[/]" if done else "[dim]·[/]"
console.print(f"{mark} [cyan]{secret.name}[/]")
crlf = " [yellow](CRLF normalised)[/]" if had_crlf else ""
console.print(f" from {secret.path}{crlf}")
console.print(f" fingerprint {fp}")
prepared: list[tuple[Secret, bytes, str, bool]] = [
(secret, *load(secret)) for secret in SECRETS
]
if args.dry_run:
console.print(f"[cyan]would set on[/] {repo}")
for secret, _pem, fp, had_crlf in prepared:
report(secret, fp, had_crlf, done=False)
console.print("[cyan]dry run[/] - nothing was sent")
return 0
for secret, pem, fp, had_crlf in prepared:
proc = run_gh(["secret", "set", secret.name, "--repo", repo], stdin=pem)
if proc.returncode != 0:
error("upload failed", f"{secret.name}: {proc.stderr.decode().strip()}")
return 1
report(secret, fp, had_crlf, done=True)
console.print(
Panel(
f"{len(prepared)} secret(s) updated on [bold]{repo}[/].\n\n"
"The fingerprints above identify which key CI now signs with. The\n"
"matching public key belongs in the image tree, not in a secret.",
title="[green]done[/]", border_style="green", box=box.ROUNDED,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())