From 7c83fc7404af9fb61f2d036e0c6c1579ebb39d6c Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Thu, 15 Oct 2026 06:54:33 +0000 Subject: [PATCH] fix(evolution): preserve COSE_Sign1 protected-header bytes on decode (#480) --- .changeset/cose-preserve-protected-bytes.md | 5 + packages/evolution/src/cose/Sign1.ts | 48 +++++-- .../test/Sign1.ProtectedBytes.test.ts | 121 ++++++++++++++++++ 3 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 .changeset/cose-preserve-protected-bytes.md create mode 100644 packages/evolution/test/Sign1.ProtectedBytes.test.ts diff --git a/.changeset/cose-preserve-protected-bytes.md b/.changeset/cose-preserve-protected-bytes.md new file mode 100644 index 00000000..3a9e2062 --- /dev/null +++ b/.changeset/cose-preserve-protected-bytes.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +COSE_Sign1 decoding now preserves the original protected-header bytes and replays them in `signedData()` and on re-encode, instead of rebuilding the Sig_structure from a canonical re-encode of the parsed HeaderMap. Per RFC 9052 the protected header is a `bstr` whose exact bytes are what the signature covers, so a valid signature from a signer whose protected-header encoding is non-minimal (for example a label written as a two-byte uint) is no longer rejected by `verifyData`, and a decode/encode round trip reproduces the received bytes. diff --git a/packages/evolution/src/cose/Sign1.ts b/packages/evolution/src/cose/Sign1.ts index 45232ec5..1e64076d 100644 --- a/packages/evolution/src/cose/Sign1.ts +++ b/packages/evolution/src/cose/Sign1.ts @@ -16,6 +16,18 @@ import type { Label } from "./Label.js" import { labelFromInt, labelFromText } from "./Label.js" import { fnv32a } from "./Utils.js" +/** + * Original protected-header bytes captured at decode time, keyed by the + * decoded COSESign1. The protected header is a `bstr` whose exact bytes are + * what the signature covers (RFC 9052), so `signedData()` and re-encoding + * must replay the received bytes rather than a canonical re-encode of the + * parsed HeaderMap — a signer whose encoding is non-minimal would otherwise + * have a valid signature rejected. Mirrors the `formatCache` pattern in + * Transaction.ts: only the default codec options populate the cache, and + * the WeakMap lets the bytes be garbage-collected with the instance. + */ +const protectedBytesCache = new WeakMap() + // ============================================================================ // COSESign1 // ============================================================================ @@ -68,12 +80,20 @@ export class COSESign1 extends Schema.Class("COSESign1")({ * @category Accessors */ signedData(externalAad: Uint8Array = new Uint8Array(), externalPayload?: Uint8Array): Uint8Array { - // Encode protected headers to CBOR - const protectedCbor = - this.headers.protected.headers.size === 0 - ? new Map() - : new Map(Array.from(this.headers.protected.headers.entries()).map(([label, value]) => [label.value, value])) - const protectedBytes = Schema.encodeSync(CBOR.FromBytes(CBOR.CML_DEFAULT_OPTIONS))(protectedCbor) + // Replay the received protected-header bytes when this instance was + // decoded from CBOR: those bytes, not a re-encode, are what was signed. + const preservedBytes = protectedBytesCache.get(this) + let protectedBytes: Uint8Array + if (preservedBytes !== undefined) { + protectedBytes = preservedBytes + } else { + // Encode protected headers to CBOR + const protectedCbor = + this.headers.protected.headers.size === 0 + ? new Map() + : new Map(Array.from(this.headers.protected.headers.entries()).map(([label, value]) => [label.value, value])) + protectedBytes = Schema.encodeSync(CBOR.FromBytes(CBOR.CML_DEFAULT_OPTIONS))(protectedCbor) + } // Use external payload if provided, otherwise use internal payload const payloadToUse = @@ -202,11 +222,21 @@ export const COSESign1FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEF const signature = new Ed25519Signature.Ed25519Signature({ bytes: signatureBytes }, { disableValidation: true }) const headers = headersNew(protectedResult.right, unprotectedResult.right) - return ParseResult.succeed(new COSESign1({ headers, payload, signature }, { disableValidation: true })) + const coseSign1 = new COSESign1({ headers, payload, signature }, { disableValidation: true }) + // Preserve the received protected-header bytes for signedData() and + // re-encoding. Non-default codec options opt out, as in Transaction.ts: + // custom options imply the caller wants re-canonicalization. + if (options === CBOR.CML_DEFAULT_OPTIONS) { + protectedBytesCache.set(coseSign1, new Uint8Array(protectedBytes)) + } + return ParseResult.succeed(coseSign1) }, encode: (coseSign1) => { - // Encode protected headers to bytes - const protectedBytes = Schema.encodeSync(HeaderMapFromCBORBytes(options))(coseSign1.headers.protected) + // Replay the preserved protected-header bytes when available so a + // decode/encode round trip reproduces the original encoding. + const preservedBytes = options === CBOR.CML_DEFAULT_OPTIONS ? protectedBytesCache.get(coseSign1) : undefined + const protectedBytes = + preservedBytes ?? Schema.encodeSync(HeaderMapFromCBORBytes(options))(coseSign1.headers.protected) // Encode unprotected headers to Map (not bytes!) const unprotectedCbor = new Map() diff --git a/packages/evolution/test/Sign1.ProtectedBytes.test.ts b/packages/evolution/test/Sign1.ProtectedBytes.test.ts new file mode 100644 index 00000000..a5fec2a0 --- /dev/null +++ b/packages/evolution/test/Sign1.ProtectedBytes.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest" + +import * as Address from "../src/Address.js" +import * as Bytes from "../src/Bytes.js" +import * as CBOR from "../src/CBOR.js" +import { SignData } from "../src/cose/index.js" +import * as Sign1 from "../src/cose/Sign1.js" +import * as Utils from "../src/cose/Utils.js" +import * as KeyHash from "../src/KeyHash.js" +import * as PrivateKey from "../src/PrivateKey.js" + +// Issue #480: a COSE_Sign1's protected header is a bstr whose exact bytes are +// what the signature covers (RFC 9052). A signer may emit those bytes in a +// non-minimal encoding — here the algorithm label 1 written as a two-byte +// uint (0x18 0x01) instead of one byte (0x01). Decoding must preserve the +// received bytes and signedData() / re-encoding must replay them, otherwise +// verifyData rejects a valid signature. + +const addressHexFor = (keyHash: KeyHash.KeyHash): string => + Address.toHex(new Address.Address({ networkId: 0, paymentCredential: keyHash })) + +const bstrHeader = (length: number): Array => (length < 24 ? [0x40 + length] : [0x58, length]) + +/** + * Build a COSE_Sign1 whose protected headers use the non-minimal label + * encoding, signed with `privateKey` over those exact bytes. Returns the + * assembled CBOR bytes plus the Sig_structure the signature covers. + */ +const buildNonMinimallyEncodedSign1 = ( + privateKey: PrivateKey.PrivateKey, + addressHex: string, + payload: Uint8Array +): { assembled: Uint8Array; sigStructure: Uint8Array; originalSignature: Uint8Array } => { + const genuine = SignData.signData(addressHex, payload as Utils.Payload, privateKey) + const parts = CBOR.fromCBORBytes(genuine.signature) as unknown as ReadonlyArray + const protectedMap = CBOR.fromCBORBytes(parts[0] as Uint8Array) as Map + const unprotected = parts[1] as Map + + let addressBytes: Uint8Array | undefined + for (const [key, value] of protectedMap.entries()) { + if (key === "address") addressBytes = value as Uint8Array + } + if (addressBytes === undefined) throw new Error("protected headers carry no address") + + // a2 / label 1 as 0x18 0x01 / alg value -8 (0x27) / "address" / bstr + const nonMinimalProtected = new Uint8Array([ + 0xa2, + 0x18, + 0x01, + 0x27, + 0x67, + ...new TextEncoder().encode("address"), + ...bstrHeader(addressBytes.length), + ...addressBytes + ]) + + const sigStructure = CBOR.toCBORBytes(["Signature1", nonMinimalProtected, new Uint8Array(), payload]) + const signature = PrivateKey.sign(privateKey, sigStructure) + const assembled = CBOR.toCBORBytes([nonMinimalProtected, unprotected, payload, signature.bytes]) + return { assembled, sigStructure, originalSignature: parts[3] as Uint8Array } +} + +describe("COSE_Sign1 protected-header byte preservation (#480)", () => { + it("verifyData accepts a valid signature over non-minimally encoded protected headers", () => { + const privateKey = PrivateKey.fromBytes(PrivateKey.generate()) + const keyHash = KeyHash.fromPrivateKey(privateKey) + const addressHex = addressHexFor(keyHash) + const payload = Utils.fromText("Hello, Cardano!") + + const { assembled } = buildNonMinimallyEncodedSign1(privateKey, addressHex, payload) + const genuine = SignData.signData(addressHex, payload, privateKey) + + const isValid = SignData.verifyData(addressHex, KeyHash.toHex(keyHash), payload, { + signature: assembled, + key: genuine.key + }) + expect(isValid).toBe(true) + }) + + it("signedData() replays the original protected-header bytes", () => { + const privateKey = PrivateKey.fromBytes(PrivateKey.generate()) + const keyHash = KeyHash.fromPrivateKey(privateKey) + const addressHex = addressHexFor(keyHash) + const payload = Utils.fromText("Hello, Cardano!") + + const { assembled, sigStructure } = buildNonMinimallyEncodedSign1(privateKey, addressHex, payload) + const decoded = Sign1.coseSign1FromCBORBytes(assembled) + expect(Bytes.toHex(decoded.signedData())).toBe(Bytes.toHex(sigStructure)) + }) + + it("re-encoding a decoded COSE_Sign1 reproduces the original bytes", () => { + const privateKey = PrivateKey.fromBytes(PrivateKey.generate()) + const keyHash = KeyHash.fromPrivateKey(privateKey) + const addressHex = addressHexFor(keyHash) + const payload = Utils.fromText("Hello, Cardano!") + + const { assembled } = buildNonMinimallyEncodedSign1(privateKey, addressHex, payload) + const decoded = Sign1.coseSign1FromCBORBytes(assembled) + expect(Bytes.toHex(Sign1.coseSign1ToCBORBytes(decoded))).toBe(Bytes.toHex(assembled)) + }) + + it("a signature over the canonical re-encode does not verify against different protected bytes", () => { + const privateKey = PrivateKey.fromBytes(PrivateKey.generate()) + const keyHash = KeyHash.fromPrivateKey(privateKey) + const addressHex = addressHexFor(keyHash) + const payload = Utils.fromText("Hello, Cardano!") + + const { assembled, originalSignature } = buildNonMinimallyEncodedSign1(privateKey, addressHex, payload) + // Swap the valid signature for the one signData made over the canonical + // encoding of the same headers: those bytes are not what was signed here. + const parts = CBOR.fromCBORBytes(assembled) as unknown as ReadonlyArray + const mismatched = CBOR.toCBORBytes([parts[0], parts[1], parts[2], originalSignature]) + const genuine = SignData.signData(addressHex, payload, privateKey) + + const isValid = SignData.verifyData(addressHex, KeyHash.toHex(keyHash), payload, { + signature: mismatched, + key: genuine.key + }) + expect(isValid).toBe(false) + }) +})