Skip to content

Commit f76779e

Browse files
authored
feat(storage): hash pins on S3/GCS via object metadata (#952)
Signed reland of #947 (by @jgrund). Records the SHA-256 integrity pin as object metadata (x-amz-meta-sha256 / x-goog-meta-sha256) on the S3 and GCS backends, written atomically with the object so bytes are never stored unpinned; get re-verifies the pin on read and fails closed on a mismatch. Raw uploads are validated against an RFC 9530 Repr-Digest header when present. Also adds a direct validate_storage_key() at the top of Storage::get() for defense in depth. main requires verified signatures and the original commits were unsigned, so this re-lands the reviewed diff as one web-flow-signed commit. Co-authored-by: Joe Grund <grundjoseph@gmail.com> Closes #947
1 parent ba767c6 commit f76779e

20 files changed

Lines changed: 1025 additions & 700 deletions

File tree

ARCHITECTURE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ matched the bytes, or `Unpinned` for an open-world key with no pin. Callers must
9999
verified one. A pin mismatch returns `IntegrityViolation` rather than the bytes,
100100
so the gate fails closed.
101101

102+
The pin itself belongs to the backend, which keeps it with the bytes: an NDJSON
103+
sidecar (`.nora-pins.ndjson`) on the local filesystem, and the user-defined
104+
`sha256` object metadata on S3/GCS, written atomically with the object and read
105+
back on GET/HEAD. The wrapper only validates keys and runs the gate; an object
106+
stored without a digest (or written before pins existed) simply has none and
107+
stays open-world.
108+
102109
The curation layer is a second trust boundary for proxy traffic. When mode is
103110
`enforce`, a package must pass all filters (blocklist, allowlist, namespace,
104111
integrity) before reaching storage. When mode is `audit`, blocked packages
@@ -149,9 +156,9 @@ nora/
149156
│ │ └── mod.rs # Re-exports: docker_routes(), maven_routes(), ...
150157
│ │
151158
│ ├── storage/
152-
│ │ ├── mod.rs # StorageBackend trait + Storage wrapper (validate + pin gate)
153-
│ │ ├── local.rs # Local filesystem implementation
154-
│ │ └── object.rs # Object-store implementation (S3-compatible + GCS)
159+
│ │ ├── mod.rs # StorageBackend trait + Storage wrapper (validate + verify gate)
160+
│ │ ├── local.rs # Local filesystem implementation (pins in an NDJSON sidecar)
161+
│ │ └── object.rs # Object-store implementation, S3-compatible + GCS (pins in object metadata)
155162
│ │
156163
│ ├── auth/ # Authentication (middleware + providers)
157164
│ │ ├── mod.rs # auth_middleware, provider dispatch
@@ -165,7 +172,7 @@ nora/
165172
│ ├── validation.rs # Input validation: storage keys, package names, null bytes
166173
│ │
167174
│ ├── verified.rs # Compile-time integrity witnesses (GateOutcome typestate)
168-
│ ├── hash_pin_store.rs # SHA-256 pins recorded on put(), verified on get()
175+
│ ├── hash_pin_store.rs # SHA-256 pin sidecar for the local backend (NDJSON)
169176
│ ├── digest_quarantine.rs # First-seen tracking for proxy-fetched digests
170177
│ ├── circuit_breaker.rs # Per-registry circuit breaker for upstream proxy calls
171178
│ ├── proxy_coalesce.rs # Single-flight coalescing on the proxy cache-miss path

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Changelog
22
## [Unreleased]
33

4-
### Fixed
5-
- **GC: tag-rooted mark walk kept a tag manifest's children but swept the digest-named copy of the manifest itself**, so pull-by-digest of a tagged image 404'd after the first GC run while pull-by-tag kept working. The walk now marks `manifests/sha256:<sha256(bytes)>.json` for every tag manifest — the digest alias the OCI distribution spec requires to stay pullable. Orphaned digest manifests now also take their `.meta.json` sidecar with them instead of leaking it. (#949)
4+
### Added
5+
- **Hash pins on S3/GCS via object metadata** — the SHA-256 integrity pin is no longer a local-filesystem-only feature. On object-store backends it is written as the user-defined `sha256` object metadata, atomically with the object, and read back on GET/HEAD, so buffered reads verify at rest and raw files get `ETag`, `If-None-Match` (304) and `If-Match` conditional overwrite on every backend. Pins are now a backend concern: the local backend keeps its NDJSON sidecar (same path and format, no migration), the object-store backend keeps object metadata, and the storage wrapper only validates keys and runs the fail-closed verify gate. Objects written before the upgrade carry no metadata and stay open-world until they are rewritten; `nora re-pin` rewrites the object on an object store, since object metadata cannot be changed in place.
6+
- **Raw upload integrity via `Repr-Digest` (RFC 9530)** — a raw `PUT` may declare `Repr-Digest: sha-256=:BASE64:`; NORA verifies the received body against it before committing, so a corrupted or truncated upload is rejected with `400` instead of being pinned. The pin itself is always the server-computed hash; the header only gates the commit. A `Repr-Digest` without a sha-256 entry is rejected rather than silently skipped.
67

78
## [1.2.0] - 2026-08-23
89

COMPAT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ Helm charts are stored as OCI artifacts via the Docker registry endpoints. `helm
389389
| Health check | Full | `/health` |
390390
| Swagger/OpenAPI | Full | `/api-docs` |
391391
| S3 backend | Full | AWS S3, Ceph RGW. Basic storage works on any S3-compatible; multi-replica write-serialization has a caveat — see note below. |
392-
| GCS backend | Full | Native Google Cloud Storage (`storage.mode = "gcs"`): Workload Identity / service-account JSON / ambient credentials; endpoint override for emulators and Private Google Access. Same single-writer caveat as S3 for rpm/deb publishing (in-process publish lock). Hash-pinning (at-rest integrity verification) is unavailable on ALL object-store backends, not only S3. |
392+
| GCS backend | Full | Native Google Cloud Storage (`storage.mode = "gcs"`): Workload Identity / service-account JSON / ambient credentials; endpoint override for emulators and Private Google Access. Same single-writer caveat as S3 for rpm/deb publishing (in-process publish lock). Hash-pinning (at-rest integrity verification) works on every backend: the pin is the `sha256` object metadata on S3/GCS. |
393393
| Local filesystem backend | Full | Default, content-addressable |
394394
| Activity log | Full | Recent push/pull in dashboard |
395395
| Backup/restore | Full | CLI commands |

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ All endpoints require authentication. Anonymous read is opt-in via `anonymous_re
3737
| Cargo ||| `crates.io` (sparse index) | hosted + proxy (sparse index) |
3838
| PyPI ||| `pypi.org/simple/` | hosted + proxy |
3939
| Go Modules ||| `proxy.golang.org` | proxy only (modules immutable, push not in protocol) |
40-
| Raw files ||| — (no upstream) | hosted only; conditional `PUT` (ETag/`If-Match` — local backend only; `If-None-Match: *` works on any backend) |
40+
| Raw files ||| — (no upstream) | hosted only; conditional `PUT`/`GET` (ETag, `If-Match`, `If-None-Match`) on every backend; upload verification via `Repr-Digest` (RFC 9530) |
4141
| RubyGems ||| `rubygems.org` | proxy only — `gem push` not implemented in NORA v1.1.0 |
4242
| Terraform ||| `registry.terraform.io` | proxy only; client configuration notes in COMPAT.md |
4343
| Ansible Galaxy ||| `galaxy.ansible.com` | proxy only — `ansible-galaxy collection publish` not implemented |

nora-registry/src/gc.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,12 +1366,18 @@ mod tests {
13661366
Vec::new()
13671367
})
13681368
}
1369-
async fn put(&self, _key: &str, _data: &[u8]) -> crate::storage::Result<()> {
1369+
async fn put(&self, _key: &str, _data: &[u8], _sha256: &str) -> crate::storage::Result<()> {
13701370
Ok(())
13711371
}
1372-
async fn get(&self, _key: &str) -> crate::storage::Result<axum::body::Bytes> {
1372+
async fn get(
1373+
&self,
1374+
_key: &str,
1375+
) -> crate::storage::Result<(axum::body::Bytes, Option<String>)> {
13731376
Err(crate::storage::StorageError::NotFound)
13741377
}
1378+
async fn pin(&self, _key: &str) -> Option<String> {
1379+
None
1380+
}
13751381
async fn delete(&self, _key: &str) -> crate::storage::Result<()> {
13761382
Ok(())
13771383
}
@@ -1388,6 +1394,7 @@ mod tests {
13881394
&self,
13891395
_key: &str,
13901396
_src: &std::path::Path,
1397+
_sha256: Option<&str>,
13911398
) -> crate::storage::Result<()> {
13921399
Ok(())
13931400
}
@@ -1396,11 +1403,17 @@ mod tests {
13961403
_key: &str,
13971404
) -> crate::storage::Result<(
13981405
u64,
1406+
Option<String>,
13991407
std::pin::Pin<Box<dyn tokio::io::AsyncRead + Send + Unpin>>,
14001408
)> {
14011409
Err(crate::storage::StorageError::NotFound)
14021410
}
1403-
async fn copy(&self, _src: &str, _dst: &str) -> crate::storage::Result<()> {
1411+
async fn copy(
1412+
&self,
1413+
_src: &str,
1414+
_dst: &str,
1415+
_sha256: Option<&str>,
1416+
) -> crate::storage::Result<()> {
14041417
Err(crate::storage::StorageError::NotFound)
14051418
}
14061419
}

0 commit comments

Comments
 (0)