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.
- {scpe_protocol-0.2.1/scpe_protocol.egg-info → scpe_protocol-0.2.2}/PKG-INFO +51 -18
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/README.md +50 -17
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/pyproject.toml +1 -1
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/standalone/verify_envelope.py +42 -4
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/__init__.py +1 -1
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/cli.py +22 -2
- scpe_protocol-0.2.2/scpe/context.py +123 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/results.py +42 -5
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2/scpe_protocol.egg-info}/PKG-INFO +51 -18
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/SOURCES.txt +2 -0
- scpe_protocol-0.2.2/tests/test_context_binding.py +195 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/LICENSE +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/LICENSE-SPEC +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/__init__.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/disclosure.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/level1_lint.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/producer.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/reference/standalone/__init__.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/diffinfo.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/optin.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/seal.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/testrun.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe/verify.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/dependency_links.txt +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/entry_points.txt +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/requires.txt +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/scpe_protocol.egg-info/top_level.txt +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/setup.cfg +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_action_expressions.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_action_yaml.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_diff_resolution.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_differential_verifiers.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_disclosure.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_e2e_local.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_empty_keys_anchor_parity.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_entry_points.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_gate_requires_forge_anchor.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_key_source_anchor.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_level1_lint.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_level2_implies_level1.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_no_duplicate_verifier.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_optin.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_package_is_stdlib_only.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_producer_cli.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_producer_roundtrip.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_producer_submit.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_profiles_demo.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_results_contract.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_retired_envelope_format.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_seal.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_seal_render.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_seal_results.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_spec_vectors.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_ssrf_fetch.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_stress_100prs.py +0 -0
- {scpe_protocol-0.2.1 → scpe_protocol-0.2.2}/tests/test_testrun.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 | ~
|
|
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
|
|
272
|
-
no
|
|
273
|
-
the
|
|
301
|
+
this shape — the 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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 | ~
|
|
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
|
|
244
|
-
no
|
|
245
|
-
the
|
|
273
|
+
this shape — the 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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
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
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 | ~
|
|
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
|
|
272
|
-
no
|
|
273
|
-
the
|
|
301
|
+
this shape — the 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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|