Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 35 additions & 24 deletions docs/design/specification-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ Mere persists a number of formats, several of which are signed or content-addres
| Format | Location | Discriminator | Written | Accepted |
| --- | --- | --- | --- | --- |
| Store content hash (§1) | store path name | manifest format and `schema_version` | v3 | v1, transitional, v2, v3 |
| Package manifest (§17) | `.mere/manifest.v1`, `.mere/manifest.v2`, `.mere/manifest.v3` | `schema_version` field and filename | v3 | v1, v2, v3 |
| Manifest signature (§5) | `.mere/manifest.vN.sig` | **none** | raw Ed25519 | raw Ed25519 |
| Package manifest (§17) | `.mere/manifest.v1` through `.mere/manifest.v4` | `schema_version` field and filename | v4 | v1, v2, v3, v4 |
| Manifest signature (§5) | `.mere/manifest.vN.sig` | manifest format; v4 envelope magic/version/algorithm | domain-separated v2 envelope | legacy raw Ed25519, domain-separated v2 |
| Key file | `*.pub`, `*.key` | `MEREKEY` magic, version and algorithm bytes | v1 / Ed25519 | v1 / Ed25519 |
| Generation manifest (§6) | `<generation>/` | `schema_version` field | 2 | 2 only |
| Realization manifest | named profile `root/` | `schema_version` field | 1 | 1 only |
Expand All @@ -101,7 +101,7 @@ Mere persists a number of formats, several of which are signed or content-addres

Requirements:

- A persisted format SHOULD carry an explicit version discriminator. Two do not: manifest signatures are a bare Ed25519 signature with no header, and the directory layouts are structural. A signature file therefore cannot express a different algorithm, even though the key file it verifies against records one.
- A persisted format SHOULD carry an explicit version discriminator. Directory layouts remain structural, while newly written manifest signatures carry an envelope discriminator. Legacy v1-v3 manifest signatures remain accepted as raw Ed25519 only because their manifest filename and schema freeze that historical verification contract.
- A reader MUST reject a version it does not recognize rather than attempt to interpret it. Generation and realization manifests do this strictly, accepting only the current schema; a store object or package manifest is instead tried against each accepted variant.
- A variant that is no longer written MUST be recorded here as read-only, together with what produced it. The store hash has two such variants: v1 predates metadata-aware identity, and the transitional variant exists only for packages built by the released but unversioned metadata-aware implementation.
- Accepting a variant is a standing cost. Every accepted store-hash variant is a fallback that each verification path must carry, and dropping one invalidates store objects on existing systems. Adding or removing acceptance is therefore a deliberate release decision, not an implementation detail.
Expand Down Expand Up @@ -375,13 +375,11 @@ For privileged operations, additionally verify ownership:

### 4.2 Manifest Location in Archives and Store

**In package archives** (`.pkg.tar.zst`):
- `.mere/manifest.v1` (manifest)
- `.mere/manifest.v1.sig` (signature)
**In package archives** (`.pkg.tar.zst`), newly written packages contain:
- `.mere/manifest.v4` (manifest; store content-hash schema v3)
- `.mere/manifest.v4.sig` (domain-separated signature envelope)

**In store objects** (`/mere/store/<hash>-<name>-<version>/`):
- `.mere/manifest.v1`
- `.mere/manifest.v1.sig`
**In store objects** (`/mere/store/<hash>-<name>-<version>/`), newly written packages contain the same two files. Readers continue to accept the corresponding v1, v2, and v3 manifest/signature pairs under their frozen legacy rules.

---

Expand Down Expand Up @@ -419,27 +417,40 @@ Reclamation is opportunistic and MUST NOT fail the operation that triggers it: a

### 5. Signature File Binary Format

**Blob Signing (.sig files)**:
#### Legacy manifest signatures (v1-v3, read-only)

| Field | Size | Description |
| --------- | -------- | ------------------------------------------- |
| signature | 64 bytes | Raw Ed25519 signature (`crypto_sign_BYTES`) |
Manifest v1, v2, and v3 signatures are exactly 64 raw Ed25519 bytes over the exact encoded manifest bytes:

**That's it.** No header, no version, no timestamp, no signer identifier.

**What is signed**: The exact bytes of the file being signed (for `manifest.v1.sig`, this is the raw bytes of `manifest.v1`).
```
```text
signature = ed25519_sign(secret_key, manifest_bytes)
```

**Key file formats**:
- `.pub`: 32 raw bytes (Ed25519 public key)
- `.key`: 64 raw bytes (libsodium secret key format: seed || public), permissions 0600
Their meaning is frozen. Readers MUST continue to verify them as raw signatures and MUST NOT reinterpret them as an envelope. New packages MUST NOT write this format.

#### Domain-separated manifest signature v2 (manifest v4)

Manifest v4 uses the following fixed-size envelope:

| Field | Size | Encoding |
| --- | ---: | --- |
| magic | 8 bytes | `MERESIG\0` |
| version | 2 bytes | little-endian `2` |
| algorithm | 2 bytes | little-endian `1` (Ed25519) |
| signature | 64 bytes | detached Ed25519 signature |

The signed message is:

```text
"MERE\0package-manifest\0signature-v2\0" || manifest_bytes
```

The fixed domain identifies both the object type and signing protocol. A v4 reader MUST require the exact envelope size, magic, version, and algorithm; unknown or malformed values MUST fail closed. It MUST NOT retry legacy verification. Conversely, v1-v3 readers MUST require exactly 64 raw bytes and MUST NOT accept the v2 envelope.

Manifest v4 changes only the manifest/signature protocol. Its `content_hash` continues to use store content-hash v3, so adding signature discrimination does not create a new store identity.

**Key file formats** remain unchanged and continue to use the versioned `MEREKEY` envelope described in the format table. Signature-protocol versioning does not alter key identity or encoding.

**Package manifest signing**:
- The manifest (`manifest.v1`) carries semantic meaning (name, version, content_hash, created_at)
- The signature (`manifest.v1.sig`) proves the manifest bytes are intact
- Together they provide authenticated package metadata
The manifest carries package identity and content identity. Its signature authenticates those exact bytes under a protocol-specific domain without placing signer identity inside the package; trust remains anchored in locally configured fingerprints.

---

Expand Down
12 changes: 11 additions & 1 deletion src/activation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,16 @@ fn validateGenerationStorePaths(
}

const format: package_manifest.Format = blk: {
const v4_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, package_manifest.MANIFEST_V4_FILENAME }) catch {
return ctx.fail(ActivationError.OutOfMemory, pkg.store_path, "failed to construct v4 manifest path");
};
defer ctx.allocator.free(v4_manifest_path);
const has_v4 = blk_v4: {
std.Io.Dir.accessAbsolute(path_mod.currentIo(), v4_manifest_path, .{}) catch break :blk_v4 false;
break :blk_v4 true;
};
if (has_v4) break :blk .v4;

const v3_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, package_manifest.MANIFEST_V3_FILENAME }) catch {
return ctx.fail(ActivationError.OutOfMemory, pkg.store_path, "failed to construct v3 manifest path");
};
Expand All @@ -577,7 +587,7 @@ fn validateGenerationStorePaths(
const computed = switch (format) {
.v1 => hash.calculateStoreContentHash(ctx.allocator, pkg.store_path, null),
.v2 => hash.calculateStoreContentHashV2(ctx.allocator, pkg.store_path, null),
.v3 => hash.calculateStoreContentHashV3(ctx.allocator, pkg.store_path, null),
.v3, .v4 => hash.calculateStoreContentHashV3(ctx.allocator, pkg.store_path, null),
};
const computed_hash = computed catch |err| {
return ctx.fail(switch (err) {
Expand Down
12 changes: 10 additions & 2 deletions src/import.zig
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ fn verifyManifestSignatureAndGetBytes(
return ImportError.PackageNotFound;
};

const result = sign.verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, trusted_fingerprints, loaded_keys) catch |err| {
const result = sign.verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, format.signatureFormat(), trusted_fingerprints, loaded_keys) catch |err| {
ctx.debug("manifest signature verification failed: {}", .{err});
ctx.setDiagnosticContextFmt(manifest_path, "manifest signature verification failed ({d} trusted key{s} tried)", .{
trusted_fingerprints.len,
Expand All @@ -209,6 +209,14 @@ pub const ManifestResult = struct {
};

fn detectManifestFormat(ctx: *Context, temp_dir: []const u8) !manifest.Format {
const v4_path = try std.fs.path.join(ctx.allocator, &.{ temp_dir, manifest.MANIFEST_V4_FILENAME });
defer ctx.allocator.free(v4_path);
const has_v4 = blk: {
std.Io.Dir.accessAbsolute(p.currentIo(), v4_path, .{}) catch break :blk false;
break :blk true;
};
if (has_v4) return .v4;

const v3_path = try std.fs.path.join(ctx.allocator, &.{ temp_dir, manifest.MANIFEST_V3_FILENAME });
defer ctx.allocator.free(v3_path);
const has_v3 = blk: {
Expand Down Expand Up @@ -399,7 +407,7 @@ fn computeAndVerifyContentHash(ctx: *Context, temp_dir: []const u8, pkg_manifest
const computed_hash = switch (format) {
.v1 => try hash.calculateStoreContentHash(ctx.allocator, temp_dir, null),
.v2 => try hash.calculateStoreContentHashV2(ctx.allocator, temp_dir, null),
.v3 => try hash.calculateStoreContentHashV3(ctx.allocator, temp_dir, null),
.v3, .v4 => try hash.calculateStoreContentHashV3(ctx.allocator, temp_dir, null),
};
defer ctx.allocator.free(computed_hash);

Expand Down
37 changes: 22 additions & 15 deletions src/install.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2024,8 +2024,8 @@ fn preVerifyManifest(
pkg_id: []const u8,
loaded_keys: []const sign.LoadedKey,
) !PreVerifyResult {
// Partial-extract manifest.v1 and manifest.v1.sig to a temp location
// and verify signature before doing any store operations.
// Partial-extract the newest supported manifest/signature pair and verify
// it before doing any store operations.
var verify_temp_dir = try path.createTempDir("mere-verify");
defer verify_temp_dir.cleanup();

Expand All @@ -2035,16 +2035,23 @@ fn preVerifyManifest(

ctx.debug("partial-extracting manifest for pre-verification", .{});
var format: manifest.Format = .v1;
extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V3_FILENAME) catch {};
const v3_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V3_FILENAME });
defer ctx.allocator.free(v3_probe_path);
if (path.fileExists(v3_probe_path)) {
format = .v3;
extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V4_FILENAME) catch {};
const v4_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V4_FILENAME });
defer ctx.allocator.free(v4_probe_path);
if (path.fileExists(v4_probe_path)) {
format = .v4;
} else {
extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V2_FILENAME) catch {};
const v2_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V2_FILENAME });
defer ctx.allocator.free(v2_probe_path);
if (path.fileExists(v2_probe_path)) format = .v2;
extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V3_FILENAME) catch {};
const v3_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V3_FILENAME });
defer ctx.allocator.free(v3_probe_path);
if (path.fileExists(v3_probe_path)) {
format = .v3;
} else {
extract.fileInto(ctx, cache_path, verify_dir, manifest.MANIFEST_V2_FILENAME) catch {};
const v2_probe_path = try std.fs.path.join(ctx.allocator, &.{ verify_dir, manifest.MANIFEST_V2_FILENAME });
defer ctx.allocator.free(v2_probe_path);
if (path.fileExists(v2_probe_path)) format = .v2;
}
}

try extract.fileInto(ctx, cache_path, verify_dir, format.manifestFilename());
Expand All @@ -2060,7 +2067,7 @@ fn preVerifyManifest(
}

ctx.debug("verifying manifest signature against {d} trusted fingerprints", .{repo_cache.trusted_fingerprints.len});
const result = sign.verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, repo_cache.trusted_fingerprints, loaded_keys) catch {
const result = sign.verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, format.signatureFormat(), repo_cache.trusted_fingerprints, loaded_keys) catch {
return ctx.fail(error.SignatureInvalid, pkg_id, "manifest signature verification");
};
errdefer ctx.allocator.free(result.verifying_fingerprint);
Expand All @@ -2071,7 +2078,7 @@ fn preVerifyManifest(
// verify-then-reread TOCTOU window).
const pkg_manifest = manifest.PackageManifestV1.decodeForSchema(result.manifest_bytes, format.schemaVersion()) catch {
ctx.allocator.free(result.manifest_bytes);
ctx.setDiagnosticContext(verify_dir, "manifest.v1 invalid or failed to decode");
ctx.setDiagnosticContext(verify_dir, "package manifest invalid or failed to decode");
return error.InvalidInput;
};
var parsed_manifest = ParsedManifest{
Expand Down Expand Up @@ -2190,7 +2197,7 @@ fn stageAndValidatePayload(
var content_hash: []const u8 = switch (format) {
.v1 => hash.calculateStoreContentHash(ctx.allocator, staging_dir, &hash_diag),
.v2 => hash.calculateStoreContentHashV2(ctx.allocator, staging_dir, &hash_diag),
.v3 => hash.calculateStoreContentHashV3(ctx.allocator, staging_dir, &hash_diag),
.v3, .v4 => hash.calculateStoreContentHashV3(ctx.allocator, staging_dir, &hash_diag),
} catch |err| {
const action = hash_diag.action orelse "compute content hash";
const path_label = hash_diag.path orelse staging_dir;
Expand Down Expand Up @@ -2220,7 +2227,7 @@ fn stageAndValidatePayload(
return ctx.fail(error.CorruptData, staging_dir, "manifest content hash does not match payload");
}
} else {
return ctx.fail(error.CorruptData, staging_dir, "manifest v2 content hash does not match payload and metadata");
return ctx.fail(error.CorruptData, staging_dir, "manifest content hash does not match payload and metadata");
}
}

Expand Down
39 changes: 32 additions & 7 deletions src/manifest.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,31 @@ pub const MAGIC: *const [8]u8 = "MEREMFST";
pub const SCHEMA_VERSION: u32 = 1;
pub const SCHEMA_VERSION_V2: u32 = 2;
pub const SCHEMA_VERSION_V3: u32 = 3;
pub const SCHEMA_VERSION_V4: u32 = 4;
pub const META_DIR = ".mere";
pub const MANIFEST_FILENAME = ".mere/manifest.v1";
pub const MANIFEST_SIG_FILENAME = ".mere/manifest.v1.sig";
pub const MANIFEST_V2_FILENAME = ".mere/manifest.v2";
pub const MANIFEST_V2_SIG_FILENAME = ".mere/manifest.v2.sig";
pub const MANIFEST_V3_FILENAME = ".mere/manifest.v3";
pub const MANIFEST_V3_SIG_FILENAME = ".mere/manifest.v3.sig";
pub const MANIFEST_V4_FILENAME = ".mere/manifest.v4";
pub const MANIFEST_V4_SIG_FILENAME = ".mere/manifest.v4.sig";
pub const META_KDL_FILENAME = ".mere/meta.kdl";
pub const PROJECTION_FILENAME = ".mere/projection.v1";

pub const Format = enum {
v1,
v2,
v3,
v4,

pub fn manifestFilename(self: Format) []const u8 {
return switch (self) {
.v1 => MANIFEST_FILENAME,
.v2 => MANIFEST_V2_FILENAME,
.v3 => MANIFEST_V3_FILENAME,
.v4 => MANIFEST_V4_FILENAME,
};
}

Expand All @@ -39,6 +44,7 @@ pub const Format = enum {
.v1 => MANIFEST_SIG_FILENAME,
.v2 => MANIFEST_V2_SIG_FILENAME,
.v3 => MANIFEST_V3_SIG_FILENAME,
.v4 => MANIFEST_V4_SIG_FILENAME,
};
}

Expand All @@ -47,8 +53,20 @@ pub const Format = enum {
.v1 => SCHEMA_VERSION,
.v2 => SCHEMA_VERSION_V2,
.v3 => SCHEMA_VERSION_V3,
.v4 => SCHEMA_VERSION_V4,
};
}

pub fn signatureFormat(self: Format) sign.ManifestSignatureFormat {
return switch (self) {
.v1, .v2, .v3 => .legacy_raw,
.v4 => .domain_v2,
};
}

pub fn usesStoreHashV3(self: Format) bool {
return self == .v3 or self == .v4;
}
};

pub const PackageManifestV1 = struct {
Expand Down Expand Up @@ -249,6 +267,10 @@ pub fn writeManifestV3(ctx: *Context, dir_path: []const u8, manifest: *const Pac
return writeManifestForFormat(ctx, dir_path, manifest, secret_key, .v3);
}

pub fn writeManifestV4(ctx: *Context, dir_path: []const u8, manifest: *const PackageManifestV1, secret_key: []const u8) ManifestError!void {
return writeManifestForFormat(ctx, dir_path, manifest, secret_key, .v4);
}

fn writeManifestForFormat(ctx: *Context, dir_path: []const u8, input: *const PackageManifestV1, secret_key: []const u8, format: Format) ManifestError!void {
var manifest_copy = input.*;
manifest_copy.schema_version = format.schemaVersion();
Expand All @@ -257,9 +279,10 @@ fn writeManifestForFormat(ctx: *Context, dir_path: []const u8, input: *const Pac
const manifest_bytes = try manifest.encode(ctx.allocator);
defer ctx.allocator.free(manifest_bytes);

const signature = sign.signBytes(secret_key, manifest_bytes) catch {
const signature = sign.signManifestBytes(ctx.allocator, secret_key, manifest_bytes, format.signatureFormat()) catch {
return ManifestError.SigningFailed;
};
defer ctx.allocator.free(signature);

const meta_dir_path = std.fs.path.join(ctx.allocator, &.{ dir_path, META_DIR }) catch {
return ManifestError.OutOfMemory;
Expand Down Expand Up @@ -306,7 +329,7 @@ fn writeManifestForFormat(ctx: *Context, dir_path: []const u8, input: *const Pac
};
};
defer file.close(io);
file.writeStreamingAll(io, &signature) catch |err| {
file.writeStreamingAll(io, signature) catch |err| {
return switch (err) {
error.AccessDenied => ManifestError.PermissionDenied,
else => ManifestError.FileSystem,
Expand Down Expand Up @@ -500,9 +523,11 @@ test "readManifestFile reports InvalidInput when manifest is missing" {
try std.testing.expectError(ManifestError.InvalidInput, readManifestFile(&test_env.ctx, package_dir));
}

test "manifest v3 has distinct filenames and schema" {
try std.testing.expectEqual(@as(u32, 3), Format.v3.schemaVersion());
try std.testing.expectEqualStrings(MANIFEST_V3_FILENAME, Format.v3.manifestFilename());
try std.testing.expectEqualStrings(MANIFEST_V3_SIG_FILENAME, Format.v3.signatureFilename());
try std.testing.expect(!std.mem.eql(u8, Format.v2.manifestFilename(), Format.v3.manifestFilename()));
test "manifest v4 separates signature format from store hash identity" {
try std.testing.expectEqual(@as(u32, 4), Format.v4.schemaVersion());
try std.testing.expectEqualStrings(MANIFEST_V4_FILENAME, Format.v4.manifestFilename());
try std.testing.expectEqualStrings(MANIFEST_V4_SIG_FILENAME, Format.v4.signatureFilename());
try std.testing.expectEqual(sign.ManifestSignatureFormat.domain_v2, Format.v4.signatureFormat());
try std.testing.expectEqual(sign.ManifestSignatureFormat.legacy_raw, Format.v3.signatureFormat());
try std.testing.expect(Format.v4.usesStoreHashV3());
}
Loading