Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/cose-preserve-protected-bytes.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 39 additions & 9 deletions packages/evolution/src/cose/Sign1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Uint8Array>()

// ============================================================================
// COSESign1
// ============================================================================
Expand Down Expand Up @@ -68,12 +80,20 @@ export class COSESign1 extends Schema.Class<COSESign1>("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 =
Expand Down Expand Up @@ -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<CBOR.CBOR, CBOR.CBOR>()
Expand Down
121 changes: 121 additions & 0 deletions packages/evolution/test/Sign1.ProtectedBytes.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> => (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<unknown>
const protectedMap = CBOR.fromCBORBytes(parts[0] as Uint8Array) as Map<unknown, unknown>
const unprotected = parts[1] as Map<CBOR.CBOR, CBOR.CBOR>

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<unknown>
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)
})
})