From 19d2b2a0c9bb72e6365dd7c48fe2fb0447034201 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Sat, 22 Aug 2026 20:37:46 -0400 Subject: [PATCH] Give package signatures an explicit domain --- docs/design/specification-details.md | 59 ++-- src/activation.zig | 12 +- src/import.zig | 12 +- src/install.zig | 37 ++- src/manifest.zig | 39 ++- src/packaging.zig | 6 +- src/sign.zig | 256 +++++++++++++++++- src/verify.zig | 28 +- test/testdata/signatures/README.md | 9 + .../signatures/domain-v2-envelope.hex | 1 + .../signatures/domain-v2-manifest-v4.hex | 1 + .../signatures/legacy-v3-manifest.hex | 1 + .../signatures/legacy-v3-signature.hex | 1 + test/testdata/signatures/test-public-key.hex | 1 + 14 files changed, 390 insertions(+), 73 deletions(-) create mode 100644 test/testdata/signatures/README.md create mode 100644 test/testdata/signatures/domain-v2-envelope.hex create mode 100644 test/testdata/signatures/domain-v2-manifest-v4.hex create mode 100644 test/testdata/signatures/legacy-v3-manifest.hex create mode 100644 test/testdata/signatures/legacy-v3-signature.hex create mode 100644 test/testdata/signatures/test-public-key.hex diff --git a/docs/design/specification-details.md b/docs/design/specification-details.md index 17ada94..d3b194f 100644 --- a/docs/design/specification-details.md +++ b/docs/design/specification-details.md @@ -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) | `/` | `schema_version` field | 2 | 2 only | | Realization manifest | named profile `root/` | `schema_version` field | 1 | 1 only | @@ -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. @@ -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/--/`): -- `.mere/manifest.v1` -- `.mere/manifest.v1.sig` +**In store objects** (`/mere/store/--/`), 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. --- @@ -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. --- diff --git a/src/activation.zig b/src/activation.zig index d0f4c93..44598b8 100644 --- a/src/activation.zig +++ b/src/activation.zig @@ -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"); }; @@ -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) { diff --git a/src/import.zig b/src/import.zig index 3d9bd54..501c17d 100644 --- a/src/import.zig +++ b/src/import.zig @@ -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, @@ -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: { @@ -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); diff --git a/src/install.zig b/src/install.zig index 5944a36..2f31b40 100644 --- a/src/install.zig +++ b/src/install.zig @@ -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(); @@ -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()); @@ -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); @@ -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{ @@ -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; @@ -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"); } } diff --git a/src/manifest.zig b/src/manifest.zig index 2ce5a6e..b28f4dc 100644 --- a/src/manifest.zig +++ b/src/manifest.zig @@ -11,6 +11,7 @@ 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"; @@ -18,6 +19,8 @@ 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"; @@ -25,12 +28,14 @@ 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, }; } @@ -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, }; } @@ -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 { @@ -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(); @@ -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; @@ -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, @@ -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()); } diff --git a/src/packaging.zig b/src/packaging.zig index b21a3a5..518fe6b 100644 --- a/src/packaging.zig +++ b/src/packaging.zig @@ -329,8 +329,8 @@ pub const Packager = struct { return self.fail(config.staging_dir, "failed to write meta.kdl", PackagingError.FileSystem); }; - // meta.kdl is part of the versioned v3 store identity. Compute the - // v3 hash only after writing canonical metadata. + // meta.kdl is part of the versioned v3 store identity. Manifest v4 + // keeps that identity while introducing domain-separated signatures. self.ctx.allocator.free(content_hash); content_hash = hash.calculateStoreContentHashV3(self.ctx.allocator, config.staging_dir, null) catch { return self.fail(config.staging_dir, "failed to compute v3 content hash", PackagingError.CreationFailed); @@ -347,7 +347,7 @@ pub const Packager = struct { return self.fail(content_hash, "invalid content hash hex", PackagingError.InvalidInput); }; pkg_manifest.content_hash = final_content_hash_bytes; - manifest.writeManifestV3(self.ctx, config.staging_dir, &pkg_manifest, &secret_key.key) catch { + manifest.writeManifestV4(self.ctx, config.staging_dir, &pkg_manifest, &secret_key.key) catch { self.ctx.allocator.free(content_hash); return self.fail(config.staging_dir, "failed to write final manifest", PackagingError.FileSystem); }; diff --git a/src/sign.zig b/src/sign.zig index 703fddc..9f2bd17 100644 --- a/src/sign.zig +++ b/src/sign.zig @@ -15,6 +15,17 @@ pub const SignError = Std.OutOfMemory || Std.FileSystem || Std.PermissionDenied SodiumInitFailed, }; +pub const ManifestSignatureFormat = enum { + legacy_raw, + domain_v2, +}; + +pub const MANIFEST_SIGNATURE_DOMAIN_V2 = "MERE\x00package-manifest\x00signature-v2\x00"; +pub const MANIFEST_SIGNATURE_MAGIC: *const [8]u8 = "MERESIG\x00"; +pub const MANIFEST_SIGNATURE_VERSION_V2: u16 = 2; +pub const MANIFEST_SIGNATURE_ALGORITHM_ED25519: u16 = 1; +pub const MANIFEST_SIGNATURE_ENVELOPE_V2_SIZE = 8 + 2 + 2 + c.crypto_sign_BYTES; + fn mapCryptoError(err: sign_crypto.CryptoError) SignError { switch (err) { sign_crypto.CryptoError.SodiumInitFailed => return SignError.SodiumInitFailed, @@ -262,6 +273,61 @@ pub fn verifyBytes(public_key_bytes: []const u8, msg: []const u8, sig: []const u return; } +fn manifestSignatureMessageV2(allocator: std.mem.Allocator, manifest_bytes: []const u8) SignError![]u8 { + const message = allocator.alloc(u8, MANIFEST_SIGNATURE_DOMAIN_V2.len + manifest_bytes.len) catch { + return SignError.OutOfMemory; + }; + @memcpy(message[0..MANIFEST_SIGNATURE_DOMAIN_V2.len], MANIFEST_SIGNATURE_DOMAIN_V2); + @memcpy(message[MANIFEST_SIGNATURE_DOMAIN_V2.len..], manifest_bytes); + return message; +} + +pub fn signManifestBytes( + allocator: std.mem.Allocator, + secret_key_bytes: []const u8, + manifest_bytes: []const u8, + format: ManifestSignatureFormat, +) SignError![]u8 { + return switch (format) { + .legacy_raw => blk: { + const signature = try signBytes(secret_key_bytes, manifest_bytes); + const encoded = allocator.alloc(u8, c.crypto_sign_BYTES) catch return SignError.OutOfMemory; + @memcpy(encoded, &signature); + break :blk encoded; + }, + .domain_v2 => blk: { + const message = try manifestSignatureMessageV2(allocator, manifest_bytes); + defer allocator.free(message); + const signature = try signBytes(secret_key_bytes, message); + const envelope = allocator.alloc(u8, MANIFEST_SIGNATURE_ENVELOPE_V2_SIZE) catch return SignError.OutOfMemory; + @memcpy(envelope[0..8], MANIFEST_SIGNATURE_MAGIC); + std.mem.writeInt(u16, envelope[8..10], MANIFEST_SIGNATURE_VERSION_V2, .little); + std.mem.writeInt(u16, envelope[10..12], MANIFEST_SIGNATURE_ALGORITHM_ED25519, .little); + @memcpy(envelope[12..], &signature); + break :blk envelope; + }, + }; +} + +fn decodeManifestSignature( + signature_bytes: []const u8, + format: ManifestSignatureFormat, +) SignError![]const u8 { + return switch (format) { + .legacy_raw => if (signature_bytes.len == c.crypto_sign_BYTES) + signature_bytes + else + SignError.VerifyFailed, + .domain_v2 => blk: { + if (signature_bytes.len != MANIFEST_SIGNATURE_ENVELOPE_V2_SIZE) return SignError.VerifyFailed; + if (!std.mem.eql(u8, signature_bytes[0..8], MANIFEST_SIGNATURE_MAGIC)) return SignError.VerifyFailed; + if (std.mem.readInt(u16, signature_bytes[8..10], .little) != MANIFEST_SIGNATURE_VERSION_V2) return SignError.VerifyFailed; + if (std.mem.readInt(u16, signature_bytes[10..12], .little) != MANIFEST_SIGNATURE_ALGORITHM_ED25519) return SignError.VerifyFailed; + break :blk signature_bytes[12..]; + }, + }; +} + /// Write a signature file for `file_path` using an injected or default signer/resolver. /// This centralizes the file-writing semantics so callers (like convert/packaging) do not need to /// handle signer resolution and IO themselves. @@ -495,7 +561,7 @@ fn verifyMsgAgainstTrustedKeys( all_keys: []const LoadedKey, trusted_fingerprints: []const []const u8, msg: []const u8, - signature: [c.crypto_sign_BYTES]u8, + signature: []const u8, ) SignError!VerifyWithFingerprintResult { var trusted_key_count: usize = 0; @@ -504,7 +570,7 @@ fn verifyMsgAgainstTrustedKeys( trusted_key_count += 1; // Try to verify with this key - verifyBytes(loaded_key.public_key.key[0..], msg, signature[0..]) catch { + verifyBytes(loaded_key.public_key.key[0..], msg, signature) catch { continue; // Try next key }; @@ -665,27 +731,33 @@ pub const VerifyManifestResult = struct { pub fn verifyManifestWithTrustedFingerprints( ctx: *Context, manifest_path: []const u8, - signature_path: ?[]const u8, + signature_path: []const u8, + format: ManifestSignatureFormat, trusted_fingerprints: []const []const u8, all_keys: []const LoadedKey, ) SignError!VerifyManifestResult { - // Read raw manifest bytes (manifests are signed directly, not hashed) const manifest_bytes = sign_io.readRawFile(manifest_path) catch |e| { return mapIOReadError(e); }; defer std.heap.page_allocator.free(manifest_bytes); + const signature_bytes = sign_io.readRawFile(signature_path) catch |e| { + return mapIOReadError(e); + }; + defer std.heap.page_allocator.free(signature_bytes); + const signature = try decodeManifestSignature(signature_bytes, format); + + const message = switch (format) { + .legacy_raw => manifest_bytes, + .domain_v2 => try manifestSignatureMessageV2(ctx.allocator, manifest_bytes), + }; + defer if (format == .domain_v2) ctx.allocator.free(message); + const owned_bytes = ctx.allocator.dupe(u8, manifest_bytes) catch return SignError.OutOfMemory; errdefer ctx.allocator.free(owned_bytes); - const result = try verifyMsgWithTrustedFingerprints( - ctx, - manifest_path, - manifest_bytes, - signature_path, - trusted_fingerprints, - all_keys, - ); + try requireTrustedFingerprints(ctx, trusted_fingerprints); + const result = try verifyMsgAgainstTrustedKeys(ctx, all_keys, trusted_fingerprints, message, signature); return VerifyManifestResult{ .verifying_fingerprint = result.verifying_fingerprint, .manifest_bytes = owned_bytes, @@ -736,7 +808,7 @@ test "verifyManifestWithTrustedFingerprints returns manifest bytes usable after }}; const trusted = [_][]const u8{fingerprint}; - var result = try verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, &trusted, &loaded_keys); + var result = try verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, .legacy_raw, &trusted, &loaded_keys); defer result.deinit(ctx.allocator); // Simulate the swap window this fix closes: remove the on-disk manifest @@ -764,7 +836,7 @@ fn verifyMsgWithTrustedFingerprints( // Load signature const signature = try resolveAndLoadSignature(file_path, signature_path); - const result = verifyMsgAgainstTrustedKeys(ctx, all_keys, trusted_fingerprints, msg, signature) catch |err| { + const result = verifyMsgAgainstTrustedKeys(ctx, all_keys, trusted_fingerprints, msg, &signature) catch |err| { if (err == SignError.VerifyFailed) { ctx.debug("verifyMsgWithTrustedFingerprints: no trusted key verified the signature", .{}); } @@ -2132,3 +2204,159 @@ test "SecretKey.derivePublicKey extracts correct public key" { // The derived public key should match the original try testing.expectEqualSlices(u8, &expected_public_key.key, &derived.key); } + +fn readHexFixture(allocator: std.mem.Allocator, fixture_path: []const u8) ![]u8 { + const hex_file = try sign_io.readRawFile(fixture_path); + defer std.heap.page_allocator.free(hex_file); + const hex = std.mem.trim(u8, hex_file, "\r\n"); + const bytes = try allocator.alloc(u8, hex.len / 2); + errdefer allocator.free(bytes); + _ = try std.fmt.hexToBytes(bytes, hex); + return bytes; +} + +const SignatureFixtureKeyPair = struct { + public: [c.crypto_sign_PUBLICKEYBYTES]u8, + secret: [c.crypto_sign_SECRETKEYBYTES]u8, +}; + +fn signatureFixtureKeyPair() !SignatureFixtureKeyPair { + const seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + var seed: [c.crypto_sign_SEEDBYTES]u8 = undefined; + _ = try std.fmt.hexToBytes(&seed, seed_hex); + const key_pair = try sign_crypto.deriveKeypairFromSeed(&seed); + return .{ .public = key_pair.public, .secret = key_pair.secret }; +} + +test "legacy v3 signature fixture remains byte-compatible" { + const allocator = std.testing.allocator; + const manifest_bytes = try readHexFixture(allocator, "test/testdata/signatures/legacy-v3-manifest.hex"); + defer allocator.free(manifest_bytes); + const expected_signature = try readHexFixture(allocator, "test/testdata/signatures/legacy-v3-signature.hex"); + defer allocator.free(expected_signature); + const expected_public_key = try readHexFixture(allocator, "test/testdata/signatures/test-public-key.hex"); + defer allocator.free(expected_public_key); + + const key_pair = try signatureFixtureKeyPair(); + try std.testing.expectEqualSlices(u8, expected_public_key, &key_pair.public); + + const signature = try signManifestBytes(allocator, &key_pair.secret, manifest_bytes, .legacy_raw); + defer allocator.free(signature); + try std.testing.expectEqualSlices(u8, expected_signature, signature); + try verifyBytes(expected_public_key, manifest_bytes, expected_signature); +} + +test "v4 signature fixture fixes the domain and envelope bytes" { + const allocator = std.testing.allocator; + const manifest_bytes = try readHexFixture(allocator, "test/testdata/signatures/domain-v2-manifest-v4.hex"); + defer allocator.free(manifest_bytes); + const expected_envelope = try readHexFixture(allocator, "test/testdata/signatures/domain-v2-envelope.hex"); + defer allocator.free(expected_envelope); + const expected_public_key = try readHexFixture(allocator, "test/testdata/signatures/test-public-key.hex"); + defer allocator.free(expected_public_key); + + const key_pair = try signatureFixtureKeyPair(); + const envelope = try signManifestBytes(allocator, &key_pair.secret, manifest_bytes, .domain_v2); + defer allocator.free(envelope); + try std.testing.expectEqualSlices(u8, expected_envelope, envelope); + + const signature = try decodeManifestSignature(envelope, .domain_v2); + const message = try manifestSignatureMessageV2(allocator, manifest_bytes); + defer allocator.free(message); + try verifyBytes(expected_public_key, message, signature); + try std.testing.expectError(SignError.VerifyFailed, verifyBytes(expected_public_key, manifest_bytes, signature)); + + const wrong_domain_message = try allocator.dupe(u8, message); + defer allocator.free(wrong_domain_message); + wrong_domain_message[5] ^= 0x01; + try std.testing.expectError(SignError.VerifyFailed, verifyBytes(expected_public_key, wrong_domain_message, signature)); + + const changed_manifest_message = try allocator.dupe(u8, message); + defer allocator.free(changed_manifest_message); + changed_manifest_message[MANIFEST_SIGNATURE_DOMAIN_V2.len + 8] = 3; + try std.testing.expectError(SignError.VerifyFailed, verifyBytes(expected_public_key, changed_manifest_message, signature)); +} + +test "trusted manifest verification dispatches frozen legacy and v4 fixtures without fallback" { + const allocator = std.testing.allocator; + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + allocator.destroy(test_env); + } + + const legacy_manifest = try readHexFixture(allocator, "test/testdata/signatures/legacy-v3-manifest.hex"); + defer allocator.free(legacy_manifest); + const legacy_signature = try readHexFixture(allocator, "test/testdata/signatures/legacy-v3-signature.hex"); + defer allocator.free(legacy_signature); + const v4_manifest = try readHexFixture(allocator, "test/testdata/signatures/domain-v2-manifest-v4.hex"); + defer allocator.free(v4_manifest); + const v4_envelope = try readHexFixture(allocator, "test/testdata/signatures/domain-v2-envelope.hex"); + defer allocator.free(v4_envelope); + const public_key_bytes = try readHexFixture(allocator, "test/testdata/signatures/test-public-key.hex"); + defer allocator.free(public_key_bytes); + + const legacy_manifest_path = try std.fs.path.join(allocator, &.{ test_env.path, "manifest.v3" }); + defer allocator.free(legacy_manifest_path); + const legacy_signature_path = try std.fs.path.join(allocator, &.{ test_env.path, "manifest.v3.sig" }); + defer allocator.free(legacy_signature_path); + const v4_manifest_path = try std.fs.path.join(allocator, &.{ test_env.path, "manifest.v4" }); + defer allocator.free(v4_manifest_path); + const v4_signature_path = try std.fs.path.join(allocator, &.{ test_env.path, "manifest.v4.sig" }); + defer allocator.free(v4_signature_path); + + const fixture_files = [_]struct { path: []const u8, bytes: []const u8 }{ + .{ .path = legacy_manifest_path, .bytes = legacy_manifest }, + .{ .path = legacy_signature_path, .bytes = legacy_signature }, + .{ .path = v4_manifest_path, .bytes = v4_manifest }, + .{ .path = v4_signature_path, .bytes = v4_envelope }, + }; + for (fixture_files) |fixture| { + var file = try std.Io.Dir.createFileAbsolute(path_mod.currentIo(), fixture.path, .{}); + try file.writeStreamingAll(path_mod.currentIo(), fixture.bytes); + file.close(path_mod.currentIo()); + } + + var public_key: PublicKey = undefined; + @memcpy(&public_key.key, public_key_bytes); + const fingerprint = try public_key.fingerprint(allocator); + defer allocator.free(fingerprint); + const loaded_keys = [_]LoadedKey{.{ .public_key = public_key, .fingerprint = fingerprint, .path = "fixture" }}; + const trusted = [_][]const u8{fingerprint}; + + var legacy_result = try verifyManifestWithTrustedFingerprints(&test_env.ctx, legacy_manifest_path, legacy_signature_path, .legacy_raw, &trusted, &loaded_keys); + defer legacy_result.deinit(test_env.ctx.allocator); + try std.testing.expectEqualSlices(u8, legacy_manifest, legacy_result.manifest_bytes); + + var v4_result = try verifyManifestWithTrustedFingerprints(&test_env.ctx, v4_manifest_path, v4_signature_path, .domain_v2, &trusted, &loaded_keys); + defer v4_result.deinit(test_env.ctx.allocator); + try std.testing.expectEqualSlices(u8, v4_manifest, v4_result.manifest_bytes); + + try std.testing.expectError(SignError.VerifyFailed, verifyManifestWithTrustedFingerprints(&test_env.ctx, legacy_manifest_path, legacy_signature_path, .domain_v2, &trusted, &loaded_keys)); + try std.testing.expectError(SignError.VerifyFailed, verifyManifestWithTrustedFingerprints(&test_env.ctx, v4_manifest_path, v4_signature_path, .legacy_raw, &trusted, &loaded_keys)); +} + +test "manifest signature formats reject substitution and malformed envelopes" { + const allocator = std.testing.allocator; + const legacy_signature = try readHexFixture(allocator, "test/testdata/signatures/legacy-v3-signature.hex"); + defer allocator.free(legacy_signature); + const envelope = try readHexFixture(allocator, "test/testdata/signatures/domain-v2-envelope.hex"); + defer allocator.free(envelope); + + try std.testing.expectError(SignError.VerifyFailed, decodeManifestSignature(legacy_signature, .domain_v2)); + try std.testing.expectError(SignError.VerifyFailed, decodeManifestSignature(envelope, .legacy_raw)); + + var malformed: [MANIFEST_SIGNATURE_ENVELOPE_V2_SIZE]u8 = undefined; + @memcpy(&malformed, envelope); + malformed[0] ^= 0xff; + try std.testing.expectError(SignError.VerifyFailed, decodeManifestSignature(&malformed, .domain_v2)); + + @memcpy(&malformed, envelope); + std.mem.writeInt(u16, malformed[8..10], 99, .little); + try std.testing.expectError(SignError.VerifyFailed, decodeManifestSignature(&malformed, .domain_v2)); + + @memcpy(&malformed, envelope); + std.mem.writeInt(u16, malformed[10..12], 99, .little); + try std.testing.expectError(SignError.VerifyFailed, decodeManifestSignature(&malformed, .domain_v2)); +} diff --git a/src/verify.zig b/src/verify.zig index 87f3b17..c34362e 100644 --- a/src/verify.zig +++ b/src/verify.zig @@ -229,6 +229,12 @@ fn verifyStore( }; const format: manifest.Format = blk: { + const v4_path = std.fs.path.join(ctx.allocator, &.{ entry_path, manifest.MANIFEST_V4_FILENAME }) catch { + return ctx.fail(VerifyError.OutOfMemory, entry_path, "failed to construct v4 manifest path"); + }; + defer ctx.allocator.free(v4_path); + if (std.Io.Dir.accessAbsolute(path_mod.currentIo(), v4_path, .{})) |_| break :blk .v4 else |_| {} + const v3_path = std.fs.path.join(ctx.allocator, &.{ entry_path, manifest.MANIFEST_V3_FILENAME }) catch { return ctx.fail(VerifyError.OutOfMemory, entry_path, "failed to construct v3 manifest path"); }; @@ -254,26 +260,26 @@ fn verifyStore( std.Io.Dir.accessAbsolute(path_mod.currentIo(), manifest_path, .{}) catch { result.store_issues += 1; - try addIssue(ctx, result, .store, entry_path, if (format == .v2) "manifest.v2 missing" else "manifest.v1 missing"); + try addIssue(ctx, result, .store, entry_path, "package manifest missing"); continue; }; std.Io.Dir.accessAbsolute(path_mod.currentIo(), sig_path, .{}) catch { result.store_issues += 1; - try addIssue(ctx, result, .store, entry_path, if (format == .v2) "manifest.v2.sig missing" else "manifest.v1.sig missing"); + try addIssue(ctx, result, .store, entry_path, "package manifest signature missing"); continue; }; const manifest_bytes = manifest.readManifestFileForFormat(ctx, entry_path, format) catch { result.store_issues += 1; - try addIssue(ctx, result, .store, entry_path, if (format == .v2) "failed to read manifest.v2" else "failed to read manifest.v1"); + try addIssue(ctx, result, .store, entry_path, "failed to read package manifest"); continue; }; defer ctx.allocator.free(manifest_bytes); const pkg_manifest = manifest.PackageManifestV1.decodeForSchema(manifest_bytes, format.schemaVersion()) catch { result.store_issues += 1; - try addIssue(ctx, result, .store, entry_path, if (format == .v2) "failed to decode manifest.v2" else "failed to decode manifest.v1"); + try addIssue(ctx, result, .store, entry_path, "failed to decode package manifest"); continue; }; @@ -300,7 +306,7 @@ fn verifyStore( } if (trusted_fingerprints.len > 0) { - var verify_result = sign.verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, trusted_fingerprints, loaded_keys.items) catch { + var verify_result = sign.verifyManifestWithTrustedFingerprints(ctx, manifest_path, sig_path, format.signatureFormat(), trusted_fingerprints, loaded_keys.items) catch { result.store_issues += 1; try addIssue(ctx, result, .store, entry_path, "manifest signature verification failed"); continue; @@ -326,7 +332,7 @@ fn verifyStore( const computed = switch (format) { .v1 => hash.calculateStoreContentHash(ctx.allocator, entry_path, null), .v2 => hash.calculateStoreContentHashV2(ctx.allocator, entry_path, null), - .v3 => hash.calculateStoreContentHashV3(ctx.allocator, entry_path, null), + .v3, .v4 => hash.calculateStoreContentHashV3(ctx.allocator, entry_path, null), }; const computed_hash = computed catch { result.store_issues += 1; @@ -518,6 +524,14 @@ fn verifyProfileManifestPackages( if (full_hash) { const format: manifest.Format = blk: { + const v4_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, manifest.MANIFEST_V4_FILENAME }) catch { + result.profile_issues += 1; + try addProfileIssue(ctx, result, pkg.store_path, profile_name, realization_name, "failed to construct manifest path"); + continue; + }; + defer ctx.allocator.free(v4_manifest_path); + if (std.Io.Dir.accessAbsolute(path_mod.currentIo(), v4_manifest_path, .{})) |_| break :blk .v4 else |_| {} + const v3_manifest_path = std.fs.path.join(ctx.allocator, &.{ pkg.store_path, manifest.MANIFEST_V3_FILENAME }) catch { result.profile_issues += 1; try addProfileIssue(ctx, result, pkg.store_path, profile_name, realization_name, "failed to construct manifest path"); @@ -538,7 +552,7 @@ fn verifyProfileManifestPackages( 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 { result.profile_issues += 1; diff --git a/test/testdata/signatures/README.md b/test/testdata/signatures/README.md new file mode 100644 index 0000000..25dee2e --- /dev/null +++ b/test/testdata/signatures/README.md @@ -0,0 +1,9 @@ +# Manifest signature compatibility fixtures + +These hex fixtures freeze Mere's manifest-signature contracts. + +- `legacy-v3-manifest.hex` and `legacy-v3-signature.hex` freeze the raw-signature contract inherited unchanged from merged-main commit `b8726de6b1c7901fb5ac3214c319ee233ff03de3`. +- `domain-v2-manifest-v4.hex` and `domain-v2-envelope.hex` freeze the v4 domain-separated message and envelope. +- `test-public-key.hex` is the public key from RFC 8032 test vector 1. Tests derive its matching keypair from the published test seed; no operational Mere key is present here. + +The fixtures are encoded as lowercase hex so changes remain reviewable. Tests decode them before verification and assert byte-for-byte writer compatibility. Changing a fixture is a persisted-format change, not routine test maintenance. diff --git a/test/testdata/signatures/domain-v2-envelope.hex b/test/testdata/signatures/domain-v2-envelope.hex new file mode 100644 index 0000000..287dec9 --- /dev/null +++ b/test/testdata/signatures/domain-v2-envelope.hex @@ -0,0 +1 @@ +4d45524553494700020001000b7eb91a225fb688c12e2c805ef9e1c3c6f2d8b09864cebb7a80e50c21d44a60b57aab70e4e966b7f0442075e6239f96de9f364eb77b3b344886845c5cf5ef0a diff --git a/test/testdata/signatures/domain-v2-manifest-v4.hex b/test/testdata/signatures/domain-v2-manifest-v4.hex new file mode 100644 index 0000000..a54b464 --- /dev/null +++ b/test/testdata/signatures/domain-v2-manifest-v4.hex @@ -0,0 +1 @@ +4d4552454d465354040000000000000000000000010000000100000078010000007001000000314242424242424242424242424242424242424242424242424242424242424242 diff --git a/test/testdata/signatures/legacy-v3-manifest.hex b/test/testdata/signatures/legacy-v3-manifest.hex new file mode 100644 index 0000000..b877d84 --- /dev/null +++ b/test/testdata/signatures/legacy-v3-manifest.hex @@ -0,0 +1 @@ +4d4552454d465354030000000000000000000000010000000100000078010000007001000000314242424242424242424242424242424242424242424242424242424242424242 diff --git a/test/testdata/signatures/legacy-v3-signature.hex b/test/testdata/signatures/legacy-v3-signature.hex new file mode 100644 index 0000000..617517f --- /dev/null +++ b/test/testdata/signatures/legacy-v3-signature.hex @@ -0,0 +1 @@ +07c139059b75ba91416d5bcb79a56e9d197c736e8f56b89ba075abd52be765010571ad5085d0769d10f242d12d134805c5167a40887e5f10ee712c452694d80a diff --git a/test/testdata/signatures/test-public-key.hex b/test/testdata/signatures/test-public-key.hex new file mode 100644 index 0000000..992aa3e --- /dev/null +++ b/test/testdata/signatures/test-public-key.hex @@ -0,0 +1 @@ +d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a