/** * RFC 9052 COSE_Sign1 envelope tests for GridStamp Merkle batches. * * Covers: * - Envelope decodes as a 4-element CBOR array (protected, unprotected, payload, signature) * - Protected header declares alg: -8 (EdDSA, RFC 9053 §2.2) * - Signature verifies against the same Ed25519 key used to sign Remote ID logs * - Tampered envelopes fail verification */ import { describe, it, expect } from 'vitest'; import { Encoder } from 'cbor-x'; import { buildMerkleRoot, buildTreeHead, wrapInCOSESign1, verifyCOSESign1, encodeCoseMerklePayload, signRemoteId, generateRemoteIdKey, COSE_ALG_EDDSA, COSE_HEADER_ALG, F3411IdType, F3411UaType, type RemoteIdMessage, } from '../../src/remoteid/index.js'; const decoder = new Encoder({ useRecords: false, tagUint8Array: false }); const BASIC: RemoteIdMessage = { kind: 'basic_id', idType: F3411IdType.SERIAL_NUMBER, uaType: F3411UaType.ROTORCRAFT_HELI_OR_MULTIROTOR, uasId: 'COSE-SIGN1-TEST-0001', }; function makeEnvelope(count = 5, leafIndex = 2) { const key = generateRemoteIdKey(); const logs = Array.from({ length: count }, (_, i) => signRemoteId(BASIC, key, 1_700_000_000_000 + i), ); const batch = buildMerkleRoot(logs); const head = buildTreeHead(batch, 1_700_000_100_000); const proof = batch.inclusion_proof(leafIndex); const envelope = wrapInCOSESign1(head, proof, key.privateKey); return { key, logs, batch, head, proof, envelope }; } describe('wrapInCOSESign1 — structure', () => { it('produces valid CBOR that decodes to a 4-element array', () => { const { envelope } = makeEnvelope(); const decoded = decoder.decode(envelope.bytes); expect(Array.isArray(decoded)).toBe(true); expect((decoded as unknown[]).length).toBe(4); }); it('elements: [protected bstr, unprotected {}, payload bstr, signature bstr]', () => { const { envelope } = makeEnvelope(); const [protectedHeader, unprotected, payload, signature] = decoder.decode(envelope.bytes) as [ Buffer | Uint8Array, unknown, Buffer | Uint8Array, Buffer | Uint8Array, ]; // protected header is a bstr (Buffer after decode) expect(protectedHeader).toBeInstanceOf(Uint8Array); // unprotected is an empty map/object expect(typeof unprotected).toBe('object'); expect(unprotected).not.toBeNull(); expect(Object.keys(unprotected as Record).length).toBe(0); // payload bstr expect(payload).toBeInstanceOf(Uint8Array); // signature is 64 bytes (Ed25519) expect(signature).toBeInstanceOf(Uint8Array); expect(Buffer.from(signature).length).toBe(64); }); it('protected header declares alg = -8 (EdDSA, RFC 9053 §2.2)', () => { const { envelope } = makeEnvelope(); // Protected header is a CBOR-encoded map wrapped as a bstr at the envelope level, // but wrapInCOSESign1 stores the decoded-map CBOR bytes directly. Either way, // decoding the protected-header bytes yields the `{ 1: -8 }` map. const protectedMap = decoder.decode(Buffer.from(envelope.protectedHeader)) as Map | Record; if (protectedMap instanceof Map) { expect(protectedMap.get(COSE_HEADER_ALG)).toBe(COSE_ALG_EDDSA); } else { // Some cbor-x decode paths return plain objects for integer-keyed maps expect((protectedMap as Record)[String(COSE_HEADER_ALG)]).toBe(COSE_ALG_EDDSA); } }); it('payload round-trips: decodes back to the tree_head + inclusion_proof bundle', () => { const { envelope, head, proof } = makeEnvelope(6, 3); const payloadBytes = encodeCoseMerklePayload(head, proof); expect(Buffer.from(envelope.payload).equals(payloadBytes)).toBe(true); const decoded = decoder.decode(envelope.payload) as { tree_head: { tree_size: number; root_hash: Uint8Array; timestamp: number }; inclusion_proof: { leaf_index: number; tree_size: number; audit_path: [Uint8Array, number][] }; }; expect(decoded.tree_head.tree_size).toBe(6); expect(decoded.tree_head.timestamp).toBe(1_700_000_100_000); expect(decoded.tree_head.root_hash.length).toBe(32); expect(decoded.inclusion_proof.leaf_index).toBe(3); expect(decoded.inclusion_proof.tree_size).toBe(6); expect(Array.isArray(decoded.inclusion_proof.audit_path)).toBe(true); }); }); describe('wrapInCOSESign1 — signature', () => { it('verifies with the originating keypair', () => { const { key, envelope } = makeEnvelope(); expect(verifyCOSESign1(envelope.bytes, key.publicKey)).toBe(true); }); it('fails verification with a different keypair', () => { const { envelope } = makeEnvelope(); const other = generateRemoteIdKey(); expect(verifyCOSESign1(envelope.bytes, other.publicKey)).toBe(false); }); it('fails verification when the signature is tampered', () => { const { key, envelope } = makeEnvelope(); const decoded = decoder.decode(envelope.bytes) as [Uint8Array, unknown, Uint8Array, Uint8Array]; const sigCopy = Buffer.from(decoded[3]); sigCopy[5] = sigCopy[5]! ^ 0xff; const tamperedBytes = new Encoder({ useRecords: false, tagUint8Array: false }).encode([ decoded[0], decoded[1], decoded[2], sigCopy, ]); expect(verifyCOSESign1(Buffer.from(tamperedBytes), key.publicKey)).toBe(false); }); it('fails verification when the payload is tampered', () => { const { key, envelope } = makeEnvelope(); const decoded = decoder.decode(envelope.bytes) as [Uint8Array, unknown, Uint8Array, Uint8Array]; const payloadCopy = Buffer.from(decoded[2]); // flip a byte somewhere after the leading CBOR map header payloadCopy[payloadCopy.length - 1] = payloadCopy[payloadCopy.length - 1]! ^ 0x01; const tamperedBytes = new Encoder({ useRecords: false, tagUint8Array: false }).encode([ decoded[0], decoded[1], payloadCopy, decoded[3], ]); expect(verifyCOSESign1(Buffer.from(tamperedBytes), key.publicKey)).toBe(false); }); it('rejects non-array / wrong-arity CBOR as envelope', () => { const { key } = makeEnvelope(); const notEnvelope = new Encoder({ useRecords: false, tagUint8Array: false }).encode({ hi: 1 }); expect(verifyCOSESign1(Buffer.from(notEnvelope), key.publicKey)).toBe(false); const threeTuple = new Encoder({ useRecords: false, tagUint8Array: false }).encode([1, 2, 3]); expect(verifyCOSESign1(Buffer.from(threeTuple), key.publicKey)).toBe(false); }); });