scpe-protocol 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {scpe_protocol-0.2.1/scpe_protocol.egg-info → scpe_protocol-0.2.2}/PKG-INFO +51 -18
  2. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/README.md +50 -17
  3. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/pyproject.toml +1 -1
  4. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/standalone/verify_envelope.py +42 -4
  5. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/__init__.py +1 -1
  6. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/cli.py +22 -2
  7. scpe_protocol-0.2.2/scpe/context.py +123 -0
  8. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/results.py +42 -5
  9. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2/scpe_protocol.egg-info}/PKG-INFO +51 -18
  10. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/SOURCES.txt +2 -0
  11. scpe_protocol-0.2.2/tests/test_context_binding.py +195 -0
  12. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/LICENSE +0 -0
  13. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/LICENSE-SPEC +0 -0
  14. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/__init__.py +0 -0
  15. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/disclosure.py +0 -0
  16. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/level1_lint.py +0 -0
  17. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/producer.py +0 -0
  18. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/standalone/__init__.py +0 -0
  19. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/diffinfo.py +0 -0
  20. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/optin.py +0 -0
  21. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/seal.py +0 -0
  22. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/testrun.py +0 -0
  23. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/verify.py +0 -0
  24. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/dependency_links.txt +0 -0
  25. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/entry_points.txt +0 -0
  26. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/requires.txt +0 -0
  27. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/top_level.txt +0 -0
  28. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/setup.cfg +0 -0
  29. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_action_expressions.py +0 -0
  30. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_action_yaml.py +0 -0
  31. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_diff_resolution.py +0 -0
  32. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_differential_verifiers.py +0 -0
  33. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_disclosure.py +0 -0
  34. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_e2e_local.py +0 -0
  35. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_empty_keys_anchor_parity.py +0 -0
  36. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_entry_points.py +0 -0
  37. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_gate_requires_forge_anchor.py +0 -0
  38. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_key_source_anchor.py +0 -0
  39. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_level1_lint.py +0 -0
  40. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_level2_implies_level1.py +0 -0
  41. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_no_duplicate_verifier.py +0 -0
  42. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_optin.py +0 -0
  43. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_package_is_stdlib_only.py +0 -0
  44. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_producer_cli.py +0 -0
  45. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_producer_roundtrip.py +0 -0
  46. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_producer_submit.py +0 -0
  47. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_profiles_demo.py +0 -0
  48. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_results_contract.py +0 -0
  49. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_retired_envelope_format.py +0 -0
  50. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_seal.py +0 -0
  51. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_seal_render.py +0 -0
  52. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_seal_results.py +0 -0
  53. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_spec_vectors.py +0 -0
  54. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_ssrf_fetch.py +0 -0
  55. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_stress_100prs.py +0 -0
  56. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_testrun.py +0 -0
  57. {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_verify_cli_parity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scpe-protocol
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: SCPE: an open protocol for verifiable provenance of any digital artifact — who signed a contribution or file, what they declared about AI use, and that nothing was tampered with, verified offline with no server and no new accounts. Ships the single-file reference verifier, its producer, and a CLI (`scpe`) that seals a pull request from it.
5
5
  Author: Augusto Bastos
6
6
  License-Expression: Apache-2.0
@@ -44,7 +44,7 @@ Dynamic: license-file
44
44
  <img alt="spec" src="https://img.shields.io/badge/spec-scpe%2F0.1-41c257?style=flat-square&labelColor=0b0b0c">
45
45
  <img alt="python" src="https://img.shields.io/badge/python-3.11%2B-41c257?style=flat-square&labelColor=0b0b0c">
46
46
  <img alt="license" src="https://img.shields.io/badge/code-Apache--2.0-41c257?style=flat-square&labelColor=0b0b0c">
47
- <img alt="status" src="https://img.shields.io/badge/v0.2.1-early-d29922?style=flat-square&labelColor=0b0b0c">
47
+ <img alt="status" src="https://img.shields.io/badge/v0.2.2-early-d29922?style=flat-square&labelColor=0b0b0c">
48
48
  </p>
49
49
 
50
50
  <p align="center">
@@ -150,6 +150,16 @@ SLSA uses to sell levels. See [docs/LEVELS.md](docs/LEVELS.md).
150
150
  (target repo, base commit, a SHA-256 of the exact diff, an AI-use disclosure, and an
151
151
  optional attribution record) signed with the SSH key already on their GitHub profile
152
152
  (`ssh-keygen -Y sign -n scpe/0.1`). No new account.
153
+
154
+ > **The key has to be published as an authentication key.** `forge` verification reads
155
+ > `github.com/<login>.keys`, and GitHub serves only authentication keys there — a key added
156
+ > under *Signing keys* is real, is used by `git`, and **never appears at that URL**, so a
157
+ > contribution signed with it can only ever reach `key_source: bundled`. Add it with
158
+ > `gh ssh-key add <key>.pub --title scpe` (no `--type signing`). GitHub does publish signing
159
+ > keys, at `api.github.com/users/<login>/ssh_signing_keys`, but `<host>/<login>.keys` is the
160
+ > shape the fixed provider table is built on; reading the signing-key endpoint per provider is
161
+ > open, not decided. Filing the key in the obviously-correct place and silently losing forge
162
+ > verification is the first thing that went wrong for the protocol's own author.
153
163
  2. It travels inside a **normal pull request** — the diff in the branch, the ~1–2 KB signed
154
164
  attestation embedded in the PR body. Merging leaves the repo history clean.
155
165
  3. The **owner's side** re-derives everything itself, with no SCPE server involved: the diff's
@@ -194,20 +204,36 @@ Add a workflow that verifies every PR and posts a seal. Set `require` to gate me
194
204
  ```yaml
195
205
  # .github/workflows/scpe.yml — the step. Copy BOTH files from docs/workflows/ for the
196
206
  # fork-safe version: scpe.yml (verify) and its companion scpe-seal.yml (post the seal).
197
- - uses: augbastos/scpe@v0.2.1
207
+ - uses: augbastos/scpe@v0.2.2
198
208
  with:
199
209
  level: "1" # 1 = disclosure lint · 2 = signed envelope required
200
210
  require: "true" # fail the check on anything not verifiable
201
211
  ```
202
212
 
203
- Pin the exact tag while the protocol is pre-1.0. Every tag here is an **immutable alias** — it
204
- points at one commit and is never moved, so a fix arrives as a new tag you adopt by editing the
205
- pin, not as a silent change under the one you already wrote. `v0.2.1` is that in practice: `v0.2`
206
- shipped a seal banner that read `VERIFIED` on unattested PRs and a workflow template GitHub
207
- refuses to register, and both were fixed by publishing a new tag rather than by moving the old
208
- one. The `v0.2` line is also the first in which the Action verifies the `scpe/0.1` envelope
209
- itself; `v0.1.x` verified a different, now-removed format, so upgrading from it is a behaviour
210
- change and not a patch — [docs/MIGRATION.md](docs/MIGRATION.md) has the steps.
213
+ ### Version policy
214
+
215
+ Pin the exact tag while the protocol is pre-1.0.
216
+
217
+ - **Every tag is an immutable alias.** It points at one commit and is never moved. A fix
218
+ arrives as a *new* tag you adopt by editing the pin, never as a silent change under the one
219
+ you already wrote. Both `v0.2.1` and `v0.2.2` exist for that reason rather than because
220
+ `v0.2` was re-pointed.
221
+ - **The cost is that fixes do not reach you on their own** — including security fixes. Watch
222
+ the releases, or let Dependabot's `github-actions` ecosystem open the bump as a pull
223
+ request you can read before merging.
224
+ - **A patch tag can change behaviour when the old behaviour was wrong.** `v0.2.2` refuses
225
+ contributions that `v0.2.1` accepted, because `v0.2.1` accepted some it should not have.
226
+ Read the [CHANGELOG](CHANGELOG.md) before moving a pin; "patch" here means the protocol
227
+ did not change, not that nothing did.
228
+ - **`spec_version` is a separate number from the tag.** The protocol is `scpe/0.1` across
229
+ every `v0.2.x` release. See [CHANGELOG.md](CHANGELOG.md) for the three axes and why
230
+ reading one as the other is the easiest mistake to make here.
231
+ - **The `v0.1.x` line is legacy, not merely old.** It verifies an envelope format that is not
232
+ in `spec/`, so upgrading from it is a behaviour change and not a patch —
233
+ [docs/MIGRATION.md](docs/MIGRATION.md) has the steps.
234
+
235
+ **Which tag to be on:** `v0.2.2`. `v0.2.1` and `v0.2` accept a valid envelope replayed from
236
+ another repository (see [SECURITY.md](SECURITY.md)); `v0.2.2` refuses it.
211
237
 
212
238
  The Action uses a fork-safe two-job split, and the two jobs live in **two files**: the untrusted
213
239
  job in `scpe.yml` (which runs contributor code) holds no secrets, and only the trusted follow-up
@@ -253,10 +279,14 @@ vector reaches the `forge` anchor.
253
279
  |---|---|
254
280
  | PR-body attestation (manifest + sig, base64) | 1.1–1.5 KB |
255
281
  | Standalone envelope (3-file / 27-line PR, zipped) | ~1.5 KB |
256
- | Verify wall-time | ~210 ms cold CLI · ~39 ms in a warm process |
282
+ | Verify wall-time | ~200 ms cold process · ~31 ms warm |
257
283
 
258
284
  An `artifact` subject adds its payload size on top of ~800 B fixed overhead. Order-of-magnitude,
259
- single machine — not a formal benchmark suite.
285
+ single machine — not a formal benchmark suite. Re-run it yourself rather than taking the number:
286
+ `python tests/bench_verify.py <envelope.zip> --keys <login.keys>` prints the median of 15 runs
287
+ for both paths. The cold figure is a fresh interpreter per run; the warm one re-verifies
288
+ in-process, which is what a batch consumer sees after import cost is paid once. Both use the
289
+ `flag` anchor, so no network is in the measurement.
260
290
 
261
291
  ## Where it sits
262
292
 
@@ -268,13 +298,16 @@ single machine — not a formal benchmark suite.
268
298
  - **A different layer from build provenance.** Sigstore / SLSA / in-toto attest *artifacts and
269
299
  builds*; SCPE attests a *contribution*, at the pull-request boundary.
270
300
  - **Direct prior art:** `patatt` + `b4` ([kernel.org](https://github.com/mricon/patatt)) have run
271
- this exact pattern — self-sign a patch with a key the platform publishes, verify independently,
272
- no CA, no server — on the Linux kernel's mailing list for years. SCPE applies the same shape to
273
- the GitHub pull-request boundary.
301
+ this shapethe contributor self-signs a patch, the recipient verifies independently, no CA,
302
+ no server — on the Linux kernel's mailing list for years. They differ from SCPE in where the
303
+ key comes from: `patatt` keeps a keyring in the repository, so the project curates who may
304
+ sign; SCPE reads the keys the forge already publishes for the account, so there is nothing to
305
+ curate and nothing to depend on but the forge. Same shape, different trust root, applied to
306
+ the pull-request boundary instead of a mailing list.
274
307
 
275
308
  ## Status
276
309
 
277
- **v0.2.1 — early.** This is a specification plus a reference implementation (a single-file
310
+ **v0.2.2 — early.** This is a specification plus a reference implementation (a single-file
278
311
  verifier, a producer, and a maintainer-side Action). The full test suite — including a 100-PR
279
312
  stress proof and a local end-to-end — runs on every push; the CI badge above is its live
280
313
  result. Two more independent verifiers, in Go and Rust, reach the same verdict as the Python
@@ -288,7 +321,7 @@ accept. There is **no external adoption yet**. It is not a hosted service and ne
288
321
  **`v0.1.x` is legacy: its signed-envelope path verifies a format that is not in this spec.** The
289
322
  Action at those tags checked a second envelope format that shipped inside the package and was
290
323
  deleted in `0.2.0`. A workflow still pinned there does not fail — it keeps posting seals for a
291
- format nothing here produces, and once `0.2.0` reaches PyPI it breaks instead, because that
324
+ format nothing here produces and now that `0.2.x` is on PyPI it breaks outright instead, because that
292
325
  Action resolved its verifier from the package index at run time rather than from its own
293
326
  checkout. [CHANGELOG.md](CHANGELOG.md) has what moved; [docs/MIGRATION.md](docs/MIGRATION.md)
294
327
  has what to do about it.
@@ -16,7 +16,7 @@
16
16
  <img alt="spec" src="https://img.shields.io/badge/spec-scpe%2F0.1-41c257?style=flat-square&labelColor=0b0b0c">
17
17
  <img alt="python" src="https://img.shields.io/badge/python-3.11%2B-41c257?style=flat-square&labelColor=0b0b0c">
18
18
  <img alt="license" src="https://img.shields.io/badge/code-Apache--2.0-41c257?style=flat-square&labelColor=0b0b0c">
19
- <img alt="status" src="https://img.shields.io/badge/v0.2.1-early-d29922?style=flat-square&labelColor=0b0b0c">
19
+ <img alt="status" src="https://img.shields.io/badge/v0.2.2-early-d29922?style=flat-square&labelColor=0b0b0c">
20
20
  </p>
21
21
 
22
22
  <p align="center">
@@ -122,6 +122,16 @@ SLSA uses to sell levels. See [docs/LEVELS.md](docs/LEVELS.md).
122
122
  (target repo, base commit, a SHA-256 of the exact diff, an AI-use disclosure, and an
123
123
  optional attribution record) signed with the SSH key already on their GitHub profile
124
124
  (`ssh-keygen -Y sign -n scpe/0.1`). No new account.
125
+
126
+ > **The key has to be published as an authentication key.** `forge` verification reads
127
+ > `github.com/<login>.keys`, and GitHub serves only authentication keys there — a key added
128
+ > under *Signing keys* is real, is used by `git`, and **never appears at that URL**, so a
129
+ > contribution signed with it can only ever reach `key_source: bundled`. Add it with
130
+ > `gh ssh-key add <key>.pub --title scpe` (no `--type signing`). GitHub does publish signing
131
+ > keys, at `api.github.com/users/<login>/ssh_signing_keys`, but `<host>/<login>.keys` is the
132
+ > shape the fixed provider table is built on; reading the signing-key endpoint per provider is
133
+ > open, not decided. Filing the key in the obviously-correct place and silently losing forge
134
+ > verification is the first thing that went wrong for the protocol's own author.
125
135
  2. It travels inside a **normal pull request** — the diff in the branch, the ~1–2 KB signed
126
136
  attestation embedded in the PR body. Merging leaves the repo history clean.
127
137
  3. The **owner's side** re-derives everything itself, with no SCPE server involved: the diff's
@@ -166,20 +176,36 @@ Add a workflow that verifies every PR and posts a seal. Set `require` to gate me
166
176
  ```yaml
167
177
  # .github/workflows/scpe.yml — the step. Copy BOTH files from docs/workflows/ for the
168
178
  # fork-safe version: scpe.yml (verify) and its companion scpe-seal.yml (post the seal).
169
- - uses: augbastos/scpe@v0.2.1
179
+ - uses: augbastos/scpe@v0.2.2
170
180
  with:
171
181
  level: "1" # 1 = disclosure lint · 2 = signed envelope required
172
182
  require: "true" # fail the check on anything not verifiable
173
183
  ```
174
184
 
175
- Pin the exact tag while the protocol is pre-1.0. Every tag here is an **immutable alias** — it
176
- points at one commit and is never moved, so a fix arrives as a new tag you adopt by editing the
177
- pin, not as a silent change under the one you already wrote. `v0.2.1` is that in practice: `v0.2`
178
- shipped a seal banner that read `VERIFIED` on unattested PRs and a workflow template GitHub
179
- refuses to register, and both were fixed by publishing a new tag rather than by moving the old
180
- one. The `v0.2` line is also the first in which the Action verifies the `scpe/0.1` envelope
181
- itself; `v0.1.x` verified a different, now-removed format, so upgrading from it is a behaviour
182
- change and not a patch — [docs/MIGRATION.md](docs/MIGRATION.md) has the steps.
185
+ ### Version policy
186
+
187
+ Pin the exact tag while the protocol is pre-1.0.
188
+
189
+ - **Every tag is an immutable alias.** It points at one commit and is never moved. A fix
190
+ arrives as a *new* tag you adopt by editing the pin, never as a silent change under the one
191
+ you already wrote. Both `v0.2.1` and `v0.2.2` exist for that reason rather than because
192
+ `v0.2` was re-pointed.
193
+ - **The cost is that fixes do not reach you on their own** — including security fixes. Watch
194
+ the releases, or let Dependabot's `github-actions` ecosystem open the bump as a pull
195
+ request you can read before merging.
196
+ - **A patch tag can change behaviour when the old behaviour was wrong.** `v0.2.2` refuses
197
+ contributions that `v0.2.1` accepted, because `v0.2.1` accepted some it should not have.
198
+ Read the [CHANGELOG](CHANGELOG.md) before moving a pin; "patch" here means the protocol
199
+ did not change, not that nothing did.
200
+ - **`spec_version` is a separate number from the tag.** The protocol is `scpe/0.1` across
201
+ every `v0.2.x` release. See [CHANGELOG.md](CHANGELOG.md) for the three axes and why
202
+ reading one as the other is the easiest mistake to make here.
203
+ - **The `v0.1.x` line is legacy, not merely old.** It verifies an envelope format that is not
204
+ in `spec/`, so upgrading from it is a behaviour change and not a patch —
205
+ [docs/MIGRATION.md](docs/MIGRATION.md) has the steps.
206
+
207
+ **Which tag to be on:** `v0.2.2`. `v0.2.1` and `v0.2` accept a valid envelope replayed from
208
+ another repository (see [SECURITY.md](SECURITY.md)); `v0.2.2` refuses it.
183
209
 
184
210
  The Action uses a fork-safe two-job split, and the two jobs live in **two files**: the untrusted
185
211
  job in `scpe.yml` (which runs contributor code) holds no secrets, and only the trusted follow-up
@@ -225,10 +251,14 @@ vector reaches the `forge` anchor.
225
251
  |---|---|
226
252
  | PR-body attestation (manifest + sig, base64) | 1.1–1.5 KB |
227
253
  | Standalone envelope (3-file / 27-line PR, zipped) | ~1.5 KB |
228
- | Verify wall-time | ~210 ms cold CLI · ~39 ms in a warm process |
254
+ | Verify wall-time | ~200 ms cold process · ~31 ms warm |
229
255
 
230
256
  An `artifact` subject adds its payload size on top of ~800 B fixed overhead. Order-of-magnitude,
231
- single machine — not a formal benchmark suite.
257
+ single machine — not a formal benchmark suite. Re-run it yourself rather than taking the number:
258
+ `python tests/bench_verify.py <envelope.zip> --keys <login.keys>` prints the median of 15 runs
259
+ for both paths. The cold figure is a fresh interpreter per run; the warm one re-verifies
260
+ in-process, which is what a batch consumer sees after import cost is paid once. Both use the
261
+ `flag` anchor, so no network is in the measurement.
232
262
 
233
263
  ## Where it sits
234
264
 
@@ -240,13 +270,16 @@ single machine — not a formal benchmark suite.
240
270
  - **A different layer from build provenance.** Sigstore / SLSA / in-toto attest *artifacts and
241
271
  builds*; SCPE attests a *contribution*, at the pull-request boundary.
242
272
  - **Direct prior art:** `patatt` + `b4` ([kernel.org](https://github.com/mricon/patatt)) have run
243
- this exact pattern — self-sign a patch with a key the platform publishes, verify independently,
244
- no CA, no server — on the Linux kernel's mailing list for years. SCPE applies the same shape to
245
- the GitHub pull-request boundary.
273
+ this shapethe contributor self-signs a patch, the recipient verifies independently, no CA,
274
+ no server — on the Linux kernel's mailing list for years. They differ from SCPE in where the
275
+ key comes from: `patatt` keeps a keyring in the repository, so the project curates who may
276
+ sign; SCPE reads the keys the forge already publishes for the account, so there is nothing to
277
+ curate and nothing to depend on but the forge. Same shape, different trust root, applied to
278
+ the pull-request boundary instead of a mailing list.
246
279
 
247
280
  ## Status
248
281
 
249
- **v0.2.1 — early.** This is a specification plus a reference implementation (a single-file
282
+ **v0.2.2 — early.** This is a specification plus a reference implementation (a single-file
250
283
  verifier, a producer, and a maintainer-side Action). The full test suite — including a 100-PR
251
284
  stress proof and a local end-to-end — runs on every push; the CI badge above is its live
252
285
  result. Two more independent verifiers, in Go and Rust, reach the same verdict as the Python
@@ -260,7 +293,7 @@ accept. There is **no external adoption yet**. It is not a hosted service and ne
260
293
  **`v0.1.x` is legacy: its signed-envelope path verifies a format that is not in this spec.** The
261
294
  Action at those tags checked a second envelope format that shipped inside the package and was
262
295
  deleted in `0.2.0`. A workflow still pinned there does not fail — it keeps posting seals for a
263
- format nothing here produces, and once `0.2.0` reaches PyPI it breaks instead, because that
296
+ format nothing here produces and now that `0.2.x` is on PyPI it breaks outright instead, because that
264
297
  Action resolved its verifier from the package index at run time rather than from its own
265
298
  checkout. [CHANGELOG.md](CHANGELOG.md) has what moved; [docs/MIGRATION.md](docs/MIGRATION.md)
266
299
  has what to do about it.
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "scpe-protocol"
10
- version = "0.2.1"
10
+ version = "0.2.2"
11
11
  description = "SCPE: an open protocol for verifiable provenance of any digital artifact — who signed a contribution or file, what they declared about AI use, and that nothing was tampered with, verified offline with no server and no new accounts. Ships the single-file reference verifier, its producer, and a CLI (`scpe`) that seals a pull request from it."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -292,12 +292,42 @@ def fetch_keys(host: str, subject: str) -> bytes:
292
292
 
293
293
  # --------------------------------------------- allowed signers + SSHSIG (§8.5-6)
294
294
 
295
+ def key_fingerprint(key_line: str) -> str | None:
296
+ """The SHA256 fingerprint OpenSSH reports for one authorized-keys line, or None."""
297
+ with tempfile.TemporaryDirectory(prefix="scpe-fp-") as td:
298
+ pub = Path(td) / "k.pub"
299
+ pub.write_text(key_line.strip() + "\n", encoding="utf-8")
300
+ proc = subprocess.run(["ssh-keygen", "-lf", str(pub)], capture_output=True)
301
+ if proc.returncode != 0:
302
+ return None
303
+ parts = proc.stdout.decode("utf-8", "replace").split()
304
+ return parts[1] if len(parts) > 1 else None
305
+
306
+
295
307
  def verify_signature(manifest_bytes: bytes, sig_bytes: bytes,
296
- subject: str, keys_bytes: bytes) -> bool:
308
+ subject: str, keys_bytes: bytes,
309
+ declared_fingerprint: str | None = None) -> bool:
297
310
  key_lines = [ln.strip() for ln in keys_bytes.decode("utf-8").splitlines()
298
311
  if ln.strip()]
299
312
  if not key_lines:
300
313
  return False
314
+ # `contributor.key_fingerprint` is a MUST field, and §14 says the manifest *binds* it —
315
+ # but nothing read it, so a signed field could say anything without consequence. An
316
+ # account publishing keys A and B could ship a manifest naming A, sign with B, and
317
+ # verify: the audit record then names a key that did not sign it. Restricting the
318
+ # allowed signers to the declared key makes the field load-bearing, and a pass then
319
+ # means "the key this manifest names is published by this account AND produced this
320
+ # signature" rather than "some key it publishes did".
321
+ #
322
+ # A declared fingerprint absent from the published set yields `signature-invalid`, not
323
+ # a new status: the signature cannot be validated against the key the manifest names.
324
+ # That is what the `wrong-identity` vector already expects, so none of the eighteen
325
+ # normative expectations move.
326
+ if declared_fingerprint:
327
+ key_lines = [ln for ln in key_lines
328
+ if key_fingerprint(ln) == declared_fingerprint]
329
+ if not key_lines:
330
+ return False
301
331
  signers = "".join(
302
332
  f'{subject} namespaces="{NAMESPACE}" {ln}\n' for ln in key_lines)
303
333
  with tempfile.TemporaryDirectory(prefix="scpe-verify-") as td:
@@ -453,9 +483,17 @@ def verify(path: Path, keys_file: Path | None, diff_file: Path | None,
453
483
  # anchor this verdict rests on, so `no published keys` above reports no key_source.
454
484
  key_source = source
455
485
 
456
- # 5-6. allowed signers + SSHSIG
457
- if not verify_signature(manifest_bytes, sig_bytes, subject, keys_bytes):
458
- return R("signature-invalid", "SSHSIG verification failed")
486
+ # 5-6. allowed signers + SSHSIG, restricted to the key the manifest names.
487
+ # The declared fingerprint is inside the signed bytes, so an attacker cannot point
488
+ # it at a different key without invalidating the very signature it gates.
489
+ declared_fp = contributor.get("key_fingerprint") if isinstance(contributor, dict) else None
490
+ declared_fp = declared_fp if isinstance(declared_fp, str) and declared_fp.strip() else None
491
+ if not verify_signature(manifest_bytes, sig_bytes, subject, keys_bytes,
492
+ declared_fingerprint=declared_fp):
493
+ return R("signature-invalid",
494
+ "SSHSIG verification failed"
495
+ if declared_fp else
496
+ "SSHSIG verification failed (manifest declares no key_fingerprint)")
459
497
 
460
498
  # 7. subject integrity — dispatch on the SIGNED subject.type (SPEC §6). The
461
499
  # signature (step 5-6) already proved the subject block is authentic, so we now
@@ -13,4 +13,4 @@ from a checkout (`PYTHONPATH=<action_path> python3 -m scpe.cli`), so a CI job ve
13
13
  with the exact bytes of the tag it pinned rather than whatever a package index serves
14
14
  at that moment.
15
15
  """
16
- __version__ = "0.2.1"
16
+ __version__ = "0.2.2"
@@ -30,7 +30,8 @@ import tempfile
30
30
  from pathlib import Path
31
31
 
32
32
  from reference.standalone import verify_envelope as _ref
33
- from scpe import __version__, results as _results, seal as _seal, testrun, verify as _verify
33
+ from scpe import (__version__, context as _context, results as _results, seal as _seal,
34
+ testrun, verify as _verify)
34
35
  from scpe.optin import init_repo
35
36
 
36
37
 
@@ -94,9 +95,18 @@ def _cmd_seal(args) -> int:
94
95
  diff = ""
95
96
  if resolved.diff is not None:
96
97
  diff = resolved.diff.read_text(encoding="utf-8", errors="replace")
98
+ # Read the manifest for context ONLY after the signature verified. Before that it is
99
+ # attacker-controlled JSON, and comparing an unauthenticated `target.repo` against
100
+ # the checkout would be theatre — the attacker picks both sides.
101
+ ctx = None
102
+ if result.status == _results.VERIFIED and (args.expect_repo or args.expect_base):
103
+ ctx = _context.check(
104
+ manifest=_results.signed_manifest(resolved.path),
105
+ repo_dir=Path(args.repo), head=args.head,
106
+ expect_repo=args.expect_repo, expect_base=args.expect_base)
97
107
  data = _results.build_results(
98
108
  result, path=resolved.path, diff=diff, diff_source=resolved.diff_source,
99
- diff_note=resolved.note,
109
+ diff_note=resolved.note, context=ctx,
100
110
  # Parsed exactly like reference/level1_lint.py parses $REQUIRE, and deliberately
101
111
  # NOT an argparse `choices=` list: a caller who writes `require: True` in YAML
102
112
  # must get an informational run, not an argparse exit 2 that kills the untrusted
@@ -222,6 +232,16 @@ def main(argv: list[str] | None = None) -> int:
222
232
  sl.add_argument("--keys", default=None,
223
233
  help="offline key list; forces key_source=flag, which the seal displays")
224
234
  sl.add_argument("--artifact", default=None, help="artifact bytes for an `artifact` subject")
235
+ sl.add_argument("--expect-repo", default=None, metavar="OWNER/NAME",
236
+ help="require the manifest's signed target.repo to be this repository, "
237
+ "case-insensitively. Without it the signed target is reported and "
238
+ "never compared, which lets a valid envelope from another "
239
+ "repository verify here. CI should always pass it.")
240
+ sl.add_argument("--expect-base", action="store_true",
241
+ help="require the manifest's signed base_sha to be an ancestor of "
242
+ "--head. Ancestry rather than equality: the base branch tip moves "
243
+ "whenever anything merges, while the commit the contributor "
244
+ "diffed from does not.")
225
245
  sl.add_argument("--run-tests", action="store_true",
226
246
  help="run the repo's own test suite in this checkout")
227
247
  sl.add_argument("--require", default="false",
@@ -0,0 +1,123 @@
1
+ """Bind a verified envelope to the pull request it is being presented on.
2
+
3
+ The signature proves the manifest is authentic and the diff digest proves the change was
4
+ not altered. Neither proves the contribution is being presented *where it was signed for*.
5
+ The manifest carries `subject.target.repo` and `subject.target.base_sha` precisely so that
6
+ question can be answered, and until this module existed those two fields were read, copied
7
+ into results.json, and never compared with anything.
8
+
9
+ The gap that leaves is a replay: lift the attestation off a public pull request, open a
10
+ pull request on a different repository whose diff normalizes to the same bytes, and the
11
+ seal reports `verified` with the original signer's name on it. The signature is genuine,
12
+ the digest matches, and the context is wrong. Reproduced before this was written.
13
+
14
+ Two checks, deliberately of different shapes:
15
+
16
+ * **repo** — string equality against the repository the workflow is running in, case-folded
17
+ because forges treat `Owner/Name` and `owner/name` as one repository.
18
+
19
+ * **base** — *ancestry*, not equality. `github.event.pull_request.base.sha` is the tip of
20
+ the base branch at event time and moves whenever anyone merges anything, so demanding it
21
+ equal the signed `base_sha` would fail every open pull request the moment the branch
22
+ advanced. What the signature actually commits to is a commit the contributor diffed from,
23
+ and the honest question is whether that commit is in this pull request's history at all.
24
+ The diff digest already pins *what* changed; ancestry pins *where it came from*.
25
+
26
+ Both are policy, enforced at the gate, and neither is a SPEC §8 status: the eight statuses
27
+ are the conformance contract three implementations agree on, and a context mismatch is a
28
+ property of the presentation rather than of the envelope. An envelope that fails these is
29
+ still a perfectly valid envelope — somewhere else.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import subprocess
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ContextCheck:
40
+ """The outcome of binding an envelope to where it was presented.
41
+
42
+ `ok` is True when nothing contradicted the signed context — including when there was
43
+ nothing to check, so callers that never supply expectations behave exactly as before.
44
+ `checked` says whether any comparison actually ran, so a caller can tell "matched" from
45
+ "no expectation given" instead of reading an unqualified pass.
46
+ """
47
+
48
+ ok: bool
49
+ checked: bool
50
+ detail: str = ""
51
+
52
+ @property
53
+ def mismatch(self) -> bool:
54
+ return self.checked and not self.ok
55
+
56
+
57
+ def _same_repo(signed: str, expected: str) -> bool:
58
+ return signed.strip().casefold() == expected.strip().casefold()
59
+
60
+
61
+ def _is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool | None:
62
+ """True / False, or None when git cannot answer.
63
+
64
+ None matters: with a shallow checkout the base commit is simply absent, and treating
65
+ "not in this clone" as "not an ancestor" would fail honest contributions on a
66
+ misconfigured runner. The caller reports the ambiguity instead of guessing.
67
+ """
68
+ try:
69
+ proc = subprocess.run(
70
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", ancestor, descendant],
71
+ capture_output=True, timeout=30)
72
+ except (OSError, subprocess.SubprocessError):
73
+ return None
74
+ if proc.returncode == 0:
75
+ return True
76
+ if proc.returncode == 1:
77
+ return False
78
+ return None # 128: unknown revision, shallow clone, not a repository
79
+
80
+
81
+ def check(*, manifest: dict, repo_dir: Path, head: str | None,
82
+ expect_repo: str | None, expect_base: bool) -> ContextCheck:
83
+ """Compare a manifest's signed target against the checkout it is presented in.
84
+
85
+ `manifest` is the SIGNED manifest — call this only after the signature verified, or the
86
+ values are attacker-controlled JSON. Subjects other than `code-change` carry no target
87
+ and are skipped: an `artifact` envelope is about bytes, not about a repository.
88
+ """
89
+ subject = manifest.get("subject")
90
+ subject = subject if isinstance(subject, dict) else {}
91
+ if subject.get("type") != "code-change":
92
+ return ContextCheck(ok=True, checked=False)
93
+
94
+ target = subject.get("target")
95
+ target = target if isinstance(target, dict) else {}
96
+ signed_repo = target.get("repo")
97
+ signed_base = target.get("base_sha")
98
+
99
+ if expect_repo:
100
+ if not isinstance(signed_repo, str) or not signed_repo:
101
+ return ContextCheck(False, True,
102
+ "the manifest declares no target repository to check against")
103
+ if not _same_repo(signed_repo, expect_repo):
104
+ return ContextCheck(False, True,
105
+ f"signed for {signed_repo}, presented on {expect_repo}")
106
+
107
+ if expect_base and head:
108
+ if not isinstance(signed_base, str) or not signed_base:
109
+ return ContextCheck(False, True,
110
+ "the manifest declares no base commit to check against")
111
+ verdict = _is_ancestor(Path(repo_dir), signed_base, head)
112
+ if verdict is None:
113
+ return ContextCheck(
114
+ False, True,
115
+ f"cannot tell whether the signed base {signed_base[:12]} is in this "
116
+ "history — check out with fetch-depth: 0")
117
+ if not verdict:
118
+ return ContextCheck(
119
+ False, True,
120
+ f"the signed base {signed_base[:12]} is not an ancestor of this pull "
121
+ "request; the envelope was made against a different history")
122
+
123
+ return ContextCheck(ok=True, checked=bool(expect_repo or (expect_base and head)))
@@ -28,7 +28,7 @@ from __future__ import annotations
28
28
  from pathlib import Path
29
29
 
30
30
  from reference.standalone import verify_envelope as _ref
31
- from scpe import diffinfo, seal
31
+ from scpe import context as _context, diffinfo, seal
32
32
  from scpe.testrun import NOT_RUN
33
33
  from scpe.verify import UNAVAILABLE
34
34
 
@@ -54,6 +54,18 @@ def _display_manifest(path: Path) -> dict:
54
54
  return {}
55
55
 
56
56
 
57
+ def signed_manifest(path: Path) -> dict:
58
+ """The same bytes, for callers acting on them rather than printing them.
59
+
60
+ Identical parse; a different name because the safety condition is different. Display can
61
+ happen whatever the verdict — an `unattested` seal still shows what the manifest claimed.
62
+ Acting on a field (comparing a target repository, enforcing a gate) is only sound once
63
+ the signature over these bytes has verified, so a caller must check that first. The two
64
+ names exist so the distinction is visible at every call site instead of living in a
65
+ comment nobody re-reads."""
66
+ return _display_manifest(path)
67
+
68
+
57
69
  def _dig(obj: object, *keys: str) -> object:
58
70
  """Walk nested dicts, tolerating any shape. A manifest is attacker-controlled JSON
59
71
  until the signature verifies, so every level is checked instead of assumed."""
@@ -77,10 +89,20 @@ def _provenance(ai_disclosure: object) -> str:
77
89
 
78
90
 
79
91
  def _fail_message(status: str, detail: str, *, disclosure_present: bool,
80
- key_source: str | None = None) -> str:
92
+ key_source: str | None = None, context_detail: str = "") -> str:
81
93
  """The one line the trusted job posts when the gate closes. Specific by construction:
82
94
  the trusted job must never have to recompute a reason, and "not verifiable" without a
83
95
  cause is an unactionable check."""
96
+ if status == VERIFIED and context_detail:
97
+ # The hardest case to guess from outside: everything cryptographic is in order. The
98
+ # signature is genuine, the diff matches the digest it was signed over, the identity
99
+ # resolves — the envelope is just being presented somewhere other than where it was
100
+ # signed for. Name which, or a maintainer reads "verified" next to a red check and
101
+ # concludes the tool is broken.
102
+ return ("❌ Wrong context — the signature and the diff are both valid, but this "
103
+ f"envelope was not signed for this pull request: {context_detail}. A valid "
104
+ "envelope replayed onto another repository or another history is still a "
105
+ "valid envelope; it just does not attest to this contribution.")
84
106
  if status == VERIFIED and key_source == "bundled":
85
107
  # Rejected while the status says `verified`, which is the one case a maintainer will
86
108
  # not guess: the signature is genuine, it just proves nothing about the account named
@@ -106,7 +128,8 @@ def _fail_message(status: str, detail: str, *, disclosure_present: bool,
106
128
  def build_results(result: _ref.Result, *, path: Path, diff: str = "",
107
129
  diff_source: str = UNAVAILABLE, diff_note: str = "",
108
130
  require: bool = False, level: str = "2",
109
- tests: dict | None = None) -> dict:
131
+ tests: dict | None = None,
132
+ context: "_context.ContextCheck | None" = None) -> dict:
110
133
  """Project a verifier `Result` (plus the diff and test run that accompanied it) into
111
134
  the results.json contract."""
112
135
  manifest = _display_manifest(path)
@@ -146,14 +169,21 @@ def build_results(result: _ref.Result, *, path: Path, diff: str = "",
146
169
  # Level 2 implies level 1 (docs/LEVELS.md): a pass must carry BOTH a valid signature and
147
170
  # a declared AI-use mode. The verifier answers the first question only, so the second is
148
171
  # enforced here, at the gate — the layer that is allowed to have a policy.
149
- would_pass = verified and disclosure_present and forge_backed
172
+ # The envelope must also be presented where it was signed for. A signature and a diff
173
+ # digest together prove the change is authentic and unaltered; they say nothing about
174
+ # which repository or which history it was meant for, and the manifest carries both
175
+ # precisely so that can be answered. See scpe/context.py — before it existed, an
176
+ # attestation lifted off a public pull request verified on an unrelated repository.
177
+ context_ok = context.ok if context is not None else True
178
+ context_detail = "" if context is None or context.ok else context.detail
179
+ would_pass = verified and disclosure_present and forge_backed and context_ok
150
180
  gate_pass = (not require) or would_pass
151
181
  # Populated whenever the contribution would NOT clear a gate, even at require=false, so
152
182
  # an informational run previews exactly what enabling the gate would say. Empty means
153
183
  # there is nothing to report — never a generic "not verifiable" a job might post anyway.
154
184
  fail_message = "" if would_pass else _fail_message(
155
185
  result.status, result.detail, disclosure_present=disclosure_present,
156
- key_source=result.key_source)
186
+ key_source=result.key_source, context_detail=context_detail)
157
187
 
158
188
  stats = change.get("stats") if isinstance(change.get("stats"), dict) else None
159
189
  claimed_files = change.get("files_changed")
@@ -214,6 +244,13 @@ def build_results(result: _ref.Result, *, path: Path, diff: str = "",
214
244
  "target_repo": _str(target.get("repo")),
215
245
  "base_sha": _str(target.get("base_sha")),
216
246
  "head_sha": _str(change.get("head_sha")),
247
+ # Whether the three fields above were COMPARED with the checkout, not just copied
248
+ # out of the manifest. `context_checked: false` means no expectation was supplied
249
+ # and the signed target went unexamined — which is what every run did before
250
+ # scpe/context.py existed, and is why a consumer needs to be able to tell.
251
+ "context_checked": bool(context.checked) if context is not None else False,
252
+ "context_ok": context_ok,
253
+ "context_detail": context_detail,
217
254
 
218
255
  # ---- provenance of the numbers above, so nobody mistakes them for the protocol ----
219
256
  "risk_scan": {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scpe-protocol
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: SCPE: an open protocol for verifiable provenance of any digital artifact — who signed a contribution or file, what they declared about AI use, and that nothing was tampered with, verified offline with no server and no new accounts. Ships the single-file reference verifier, its producer, and a CLI (`scpe`) that seals a pull request from it.
5
5
  Author: Augusto Bastos
6
6
  License-Expression: Apache-2.0
@@ -44,7 +44,7 @@ Dynamic: license-file
44
44
  <img alt="spec" src="https://img.shields.io/badge/spec-scpe%2F0.1-41c257?style=flat-square&labelColor=0b0b0c">
45
45
  <img alt="python" src="https://img.shields.io/badge/python-3.11%2B-41c257?style=flat-square&labelColor=0b0b0c">
46
46
  <img alt="license" src="https://img.shields.io/badge/code-Apache--2.0-41c257?style=flat-square&labelColor=0b0b0c">
47
- <img alt="status" src="https://img.shields.io/badge/v0.2.1-early-d29922?style=flat-square&labelColor=0b0b0c">
47
+ <img alt="status" src="https://img.shields.io/badge/v0.2.2-early-d29922?style=flat-square&labelColor=0b0b0c">
48
48
  </p>
49
49
 
50
50
  <p align="center">
@@ -150,6 +150,16 @@ SLSA uses to sell levels. See [docs/LEVELS.md](docs/LEVELS.md).
150
150
  (target repo, base commit, a SHA-256 of the exact diff, an AI-use disclosure, and an
151
151
  optional attribution record) signed with the SSH key already on their GitHub profile
152
152
  (`ssh-keygen -Y sign -n scpe/0.1`). No new account.
153
+
154
+ > **The key has to be published as an authentication key.** `forge` verification reads
155
+ > `github.com/<login>.keys`, and GitHub serves only authentication keys there — a key added
156
+ > under *Signing keys* is real, is used by `git`, and **never appears at that URL**, so a
157
+ > contribution signed with it can only ever reach `key_source: bundled`. Add it with
158
+ > `gh ssh-key add <key>.pub --title scpe` (no `--type signing`). GitHub does publish signing
159
+ > keys, at `api.github.com/users/<login>/ssh_signing_keys`, but `<host>/<login>.keys` is the
160
+ > shape the fixed provider table is built on; reading the signing-key endpoint per provider is
161
+ > open, not decided. Filing the key in the obviously-correct place and silently losing forge
162
+ > verification is the first thing that went wrong for the protocol's own author.
153
163
  2. It travels inside a **normal pull request** — the diff in the branch, the ~1–2 KB signed
154
164
  attestation embedded in the PR body. Merging leaves the repo history clean.
155
165
  3. The **owner's side** re-derives everything itself, with no SCPE server involved: the diff's
@@ -194,20 +204,36 @@ Add a workflow that verifies every PR and posts a seal. Set `require` to gate me
194
204
  ```yaml
195
205
  # .github/workflows/scpe.yml — the step. Copy BOTH files from docs/workflows/ for the
196
206
  # fork-safe version: scpe.yml (verify) and its companion scpe-seal.yml (post the seal).
197
- - uses: augbastos/scpe@v0.2.1
207
+ - uses: augbastos/scpe@v0.2.2
198
208
  with:
199
209
  level: "1" # 1 = disclosure lint · 2 = signed envelope required
200
210
  require: "true" # fail the check on anything not verifiable
201
211
  ```
202
212
 
203
- Pin the exact tag while the protocol is pre-1.0. Every tag here is an **immutable alias** — it
204
- points at one commit and is never moved, so a fix arrives as a new tag you adopt by editing the
205
- pin, not as a silent change under the one you already wrote. `v0.2.1` is that in practice: `v0.2`
206
- shipped a seal banner that read `VERIFIED` on unattested PRs and a workflow template GitHub
207
- refuses to register, and both were fixed by publishing a new tag rather than by moving the old
208
- one. The `v0.2` line is also the first in which the Action verifies the `scpe/0.1` envelope
209
- itself; `v0.1.x` verified a different, now-removed format, so upgrading from it is a behaviour
210
- change and not a patch — [docs/MIGRATION.md](docs/MIGRATION.md) has the steps.
213
+ ### Version policy
214
+
215
+ Pin the exact tag while the protocol is pre-1.0.
216
+
217
+ - **Every tag is an immutable alias.** It points at one commit and is never moved. A fix
218
+ arrives as a *new* tag you adopt by editing the pin, never as a silent change under the one
219
+ you already wrote. Both `v0.2.1` and `v0.2.2` exist for that reason rather than because
220
+ `v0.2` was re-pointed.
221
+ - **The cost is that fixes do not reach you on their own** — including security fixes. Watch
222
+ the releases, or let Dependabot's `github-actions` ecosystem open the bump as a pull
223
+ request you can read before merging.
224
+ - **A patch tag can change behaviour when the old behaviour was wrong.** `v0.2.2` refuses
225
+ contributions that `v0.2.1` accepted, because `v0.2.1` accepted some it should not have.
226
+ Read the [CHANGELOG](CHANGELOG.md) before moving a pin; "patch" here means the protocol
227
+ did not change, not that nothing did.
228
+ - **`spec_version` is a separate number from the tag.** The protocol is `scpe/0.1` across
229
+ every `v0.2.x` release. See [CHANGELOG.md](CHANGELOG.md) for the three axes and why
230
+ reading one as the other is the easiest mistake to make here.
231
+ - **The `v0.1.x` line is legacy, not merely old.** It verifies an envelope format that is not
232
+ in `spec/`, so upgrading from it is a behaviour change and not a patch —
233
+ [docs/MIGRATION.md](docs/MIGRATION.md) has the steps.
234
+
235
+ **Which tag to be on:** `v0.2.2`. `v0.2.1` and `v0.2` accept a valid envelope replayed from
236
+ another repository (see [SECURITY.md](SECURITY.md)); `v0.2.2` refuses it.
211
237
 
212
238
  The Action uses a fork-safe two-job split, and the two jobs live in **two files**: the untrusted
213
239
  job in `scpe.yml` (which runs contributor code) holds no secrets, and only the trusted follow-up
@@ -253,10 +279,14 @@ vector reaches the `forge` anchor.
253
279
  |---|---|
254
280
  | PR-body attestation (manifest + sig, base64) | 1.1–1.5 KB |
255
281
  | Standalone envelope (3-file / 27-line PR, zipped) | ~1.5 KB |
256
- | Verify wall-time | ~210 ms cold CLI · ~39 ms in a warm process |
282
+ | Verify wall-time | ~200 ms cold process · ~31 ms warm |
257
283
 
258
284
  An `artifact` subject adds its payload size on top of ~800 B fixed overhead. Order-of-magnitude,
259
- single machine — not a formal benchmark suite.
285
+ single machine — not a formal benchmark suite. Re-run it yourself rather than taking the number:
286
+ `python tests/bench_verify.py <envelope.zip> --keys <login.keys>` prints the median of 15 runs
287
+ for both paths. The cold figure is a fresh interpreter per run; the warm one re-verifies
288
+ in-process, which is what a batch consumer sees after import cost is paid once. Both use the
289
+ `flag` anchor, so no network is in the measurement.
260
290
 
261
291
  ## Where it sits
262
292
 
@@ -268,13 +298,16 @@ single machine — not a formal benchmark suite.
268
298
  - **A different layer from build provenance.** Sigstore / SLSA / in-toto attest *artifacts and
269
299
  builds*; SCPE attests a *contribution*, at the pull-request boundary.
270
300
  - **Direct prior art:** `patatt` + `b4` ([kernel.org](https://github.com/mricon/patatt)) have run
271
- this exact pattern — self-sign a patch with a key the platform publishes, verify independently,
272
- no CA, no server — on the Linux kernel's mailing list for years. SCPE applies the same shape to
273
- the GitHub pull-request boundary.
301
+ this shapethe contributor self-signs a patch, the recipient verifies independently, no CA,
302
+ no server — on the Linux kernel's mailing list for years. They differ from SCPE in where the
303
+ key comes from: `patatt` keeps a keyring in the repository, so the project curates who may
304
+ sign; SCPE reads the keys the forge already publishes for the account, so there is nothing to
305
+ curate and nothing to depend on but the forge. Same shape, different trust root, applied to
306
+ the pull-request boundary instead of a mailing list.
274
307
 
275
308
  ## Status
276
309
 
277
- **v0.2.1 — early.** This is a specification plus a reference implementation (a single-file
310
+ **v0.2.2 — early.** This is a specification plus a reference implementation (a single-file
278
311
  verifier, a producer, and a maintainer-side Action). The full test suite — including a 100-PR
279
312
  stress proof and a local end-to-end — runs on every push; the CI badge above is its live
280
313
  result. Two more independent verifiers, in Go and Rust, reach the same verdict as the Python
@@ -288,7 +321,7 @@ accept. There is **no external adoption yet**. It is not a hosted service and ne
288
321
  **`v0.1.x` is legacy: its signed-envelope path verifies a format that is not in this spec.** The
289
322
  Action at those tags checked a second envelope format that shipped inside the package and was
290
323
  deleted in `0.2.0`. A workflow still pinned there does not fail — it keeps posting seals for a
291
- format nothing here produces, and once `0.2.0` reaches PyPI it breaks instead, because that
324
+ format nothing here produces and now that `0.2.x` is on PyPI it breaks outright instead, because that
292
325
  Action resolved its verifier from the package index at run time rather than from its own
293
326
  checkout. [CHANGELOG.md](CHANGELOG.md) has what moved; [docs/MIGRATION.md](docs/MIGRATION.md)
294
327
  has what to do about it.
@@ -10,6 +10,7 @@ reference/standalone/__init__.py
10
10
  reference/standalone/verify_envelope.py
11
11
  scpe/__init__.py
12
12
  scpe/cli.py
13
+ scpe/context.py
13
14
  scpe/diffinfo.py
14
15
  scpe/optin.py
15
16
  scpe/results.py
@@ -24,6 +25,7 @@ scpe_protocol.egg-info/requires.txt
24
25
  scpe_protocol.egg-info/top_level.txt
25
26
  tests/test_action_expressions.py
26
27
  tests/test_action_yaml.py
28
+ tests/test_context_binding.py
27
29
  tests/test_diff_resolution.py
28
30
  tests/test_differential_verifiers.py
29
31
  tests/test_disclosure.py
@@ -0,0 +1,195 @@
1
+ """A valid envelope must not verify on a pull request it was not signed for.
2
+
3
+ The manifest carries `subject.target.repo` and `subject.target.base_sha` so that question
4
+ can be answered. Before scpe/context.py they were read, copied into results.json, and never
5
+ compared with anything — so an attestation lifted off a public pull request produced a
6
+ `verified`, `gate_pass: true`, `key_source: forge` seal on an unrelated repository whose
7
+ diff happened to normalize to the same bytes. The signature was genuine and the digest
8
+ matched; only the context was wrong, and nothing looked at it.
9
+
10
+ These tests are written against that scenario rather than against the implementation: each
11
+ one builds the mismatch and asserts the gate closes with a reason a maintainer can act on.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from scpe import context as _context
20
+
21
+ CODE_CHANGE = {
22
+ "subject": {
23
+ "type": "code-change",
24
+ "target": {"repo": "augbastos/scpe-demo", "base_sha": "a" * 40},
25
+ "change": {"head_sha": "b" * 40},
26
+ }
27
+ }
28
+
29
+
30
+ def _check(**kw):
31
+ base = dict(manifest=CODE_CHANGE, repo_dir=Path("."), head=None,
32
+ expect_repo=None, expect_base=False)
33
+ base.update(kw)
34
+ return _context.check(**base)
35
+
36
+
37
+ # --- the replay this exists to stop ------------------------------------------------------
38
+
39
+ def test_envelope_signed_for_another_repository_is_refused():
40
+ c = _check(expect_repo="attacker/other-repo")
41
+ assert c.mismatch
42
+ assert "augbastos/scpe-demo" in c.detail and "attacker/other-repo" in c.detail
43
+
44
+
45
+ def test_matching_repository_passes_and_says_it_checked():
46
+ c = _check(expect_repo="augbastos/scpe-demo")
47
+ assert c.ok and c.checked and not c.mismatch
48
+
49
+
50
+ def test_repository_comparison_is_case_insensitive():
51
+ """Forges treat Owner/Name and owner/name as one repository. Refusing on case would
52
+ fail honest contributions for a reason no maintainer would ever guess."""
53
+ assert _check(expect_repo="AugBastos/SCPE-Demo").ok
54
+
55
+
56
+ def test_manifest_without_a_target_repo_cannot_satisfy_the_check():
57
+ """Absent is not a pass. An envelope that declares no target repository has nothing to
58
+ compare, and treating "nothing to compare" as "matches" would let an attacker opt out
59
+ of the check by omitting the field."""
60
+ m = {"subject": {"type": "code-change", "target": {}, "change": {}}}
61
+ c = _check(manifest=m, expect_repo="augbastos/scpe-demo")
62
+ assert c.mismatch
63
+
64
+
65
+ def test_target_repo_of_the_wrong_type_is_refused():
66
+ m = {"subject": {"type": "code-change", "target": {"repo": 1234}, "change": {}}}
67
+ assert _check(manifest=m, expect_repo="augbastos/scpe-demo").mismatch
68
+
69
+
70
+ # --- base ancestry, not base equality ----------------------------------------------------
71
+
72
+ def _repo(tmp_path: Path, run) -> Path:
73
+ run(["git", "init", "-q", str(tmp_path)])
74
+ run(["git", "-C", str(tmp_path), "config", "user.email", "t@example.com"])
75
+ run(["git", "-C", str(tmp_path), "config", "user.name", "t"])
76
+ return tmp_path
77
+
78
+
79
+ @pytest.fixture()
80
+ def git(tmp_path):
81
+ import subprocess
82
+
83
+ def run(cmd, **kw):
84
+ return subprocess.run(cmd, capture_output=True, check=True, **kw)
85
+
86
+ def sha(repo, rev="HEAD"):
87
+ return subprocess.run(["git", "-C", str(repo), "rev-parse", rev],
88
+ capture_output=True, text=True, check=True).stdout.strip()
89
+
90
+ repo = _repo(tmp_path, run)
91
+ (repo / "f.txt").write_text("one\n", encoding="utf-8")
92
+ run(["git", "-C", str(repo), "add", "-A"])
93
+ run(["git", "-C", str(repo), "commit", "-qm", "base"])
94
+ base = sha(repo)
95
+ (repo / "f.txt").write_text("two\n", encoding="utf-8")
96
+ run(["git", "-C", str(repo), "add", "-A"])
97
+ run(["git", "-C", str(repo), "commit", "-qm", "head"])
98
+ return repo, base, sha(repo), run, sha
99
+
100
+
101
+ def test_signed_base_in_this_history_passes(git):
102
+ repo, base, head, _run, _sha = git
103
+ m = {"subject": {"type": "code-change",
104
+ "target": {"repo": "o/n", "base_sha": base}, "change": {}}}
105
+ c = _context.check(manifest=m, repo_dir=repo, head=head,
106
+ expect_repo=None, expect_base=True)
107
+ assert c.ok and c.checked
108
+
109
+
110
+ def test_base_check_survives_the_branch_moving_on(git):
111
+ """The reason this is ancestry and not equality. `pull_request.base.sha` is the tip of
112
+ the base branch and advances whenever anything merges; the commit the contributor
113
+ diffed from does not. Equality would fail every open pull request after any merge."""
114
+ repo, base, head, run, sha = git
115
+ run(["git", "-C", str(repo), "checkout", "-q", "-b", "other", base])
116
+ (repo / "unrelated.txt").write_text("moved on\n", encoding="utf-8")
117
+ run(["git", "-C", str(repo), "add", "-A"])
118
+ run(["git", "-C", str(repo), "commit", "-qm", "someone else merged something"])
119
+ assert sha(repo) != base # the branch tip really did move
120
+ m = {"subject": {"type": "code-change",
121
+ "target": {"repo": "o/n", "base_sha": base}, "change": {}}}
122
+ c = _context.check(manifest=m, repo_dir=repo, head=head,
123
+ expect_repo=None, expect_base=True)
124
+ assert c.ok, "a moved base branch must not invalidate an honest contribution"
125
+
126
+
127
+ def test_signed_base_from_a_foreign_history_is_refused(git):
128
+ repo, _base, head, _run, _sha = git
129
+ m = {"subject": {"type": "code-change",
130
+ "target": {"repo": "o/n", "base_sha": "c" * 40}, "change": {}}}
131
+ c = _context.check(manifest=m, repo_dir=repo, head=head,
132
+ expect_repo=None, expect_base=True)
133
+ assert c.mismatch
134
+
135
+
136
+ def test_missing_base_cannot_satisfy_the_check(git):
137
+ repo, _base, head, _run, _sha = git
138
+ m = {"subject": {"type": "code-change", "target": {"repo": "o/n"}, "change": {}}}
139
+ c = _context.check(manifest=m, repo_dir=repo, head=head,
140
+ expect_repo=None, expect_base=True)
141
+ assert c.mismatch
142
+
143
+
144
+ # --- shape of the contract ---------------------------------------------------------------
145
+
146
+ def test_artifact_subjects_are_out_of_scope():
147
+ """An `artifact` envelope is about bytes, not about a repository — there is no target to
148
+ compare, and inventing one would fail every artifact contribution."""
149
+ m = {"subject": {"type": "artifact", "artifact": {"sha256": "x"}}}
150
+ c = _context.check(manifest=m, repo_dir=Path("."), head=None,
151
+ expect_repo="o/n", expect_base=True)
152
+ assert c.ok and not c.checked
153
+
154
+
155
+ def test_no_expectation_means_nothing_was_checked():
156
+ """Callers that supply no expectation must behave exactly as they did before this
157
+ existed — and results.json has to be able to say the target went unexamined, rather
158
+ than implying it matched."""
159
+ c = _check()
160
+ assert c.ok and not c.checked
161
+
162
+
163
+ # --- and the gate actually closes on it --------------------------------------------------
164
+
165
+ def test_gate_closes_on_a_context_mismatch_and_names_it():
166
+ """The check is worth nothing if the gate ignores it. A mismatch has to reach
167
+ gate_pass, and the message has to distinguish this from the other refusals: everything
168
+ cryptographic is fine here, which is exactly why "not verifiable" would mislead."""
169
+ from reference.standalone.verify_envelope import Result
170
+ from scpe import results as _results
171
+
172
+ verified = Result("verified", "", None, profile="SCPE-C", key_source="forge")
173
+ bad = _context.ContextCheck(ok=False, checked=True,
174
+ detail="signed for a/b, presented on c/d")
175
+
176
+ data = _results.build_results(verified, path=Path("nonexistent.zip"),
177
+ require=True, context=bad)
178
+ assert data["context_checked"] is True
179
+ assert data["context_ok"] is False
180
+ assert data["gate_pass"] is False, "a replayed envelope must not clear the gate"
181
+ assert "Wrong context" in data["fail_message"]
182
+ assert "signed for a/b, presented on c/d" in data["fail_message"]
183
+ assert data["status"] == "verified", (
184
+ "the envelope IS valid — the seal must not relabel it as broken, only refuse it here")
185
+
186
+
187
+ def test_gate_still_passes_when_the_context_matches():
188
+ from reference.standalone.verify_envelope import Result
189
+ from scpe import results as _results
190
+
191
+ verified = Result("verified", "", None, profile="SCPE-C", key_source="forge")
192
+ ok = _context.ContextCheck(ok=True, checked=True)
193
+ data = _results.build_results(verified, path=Path("nonexistent.zip"),
194
+ require=True, context=ok)
195
+ assert data["context_ok"] is True
File without changes
File without changes