/** * Tests for the GridStamp ASTM F3411 Remote ID module. * Covers: round-trip sign/verify, tamper detection, cross-key rejection, * deterministic Merkle root, inclusion-proof verification. */ import { describe, it, expect } from 'vitest'; import { F3411_MESSAGE_SIZE, F3411IdType, F3411UaType, F3411OpStatus, F3411HeightType, F3411DescriptionType, F3411OperatorLocationType, F3411ClassificationType, F3411OperatorIdType, generateRemoteIdKey, signRemoteId, verifyRemoteId, encodeRemoteIdMessage, buildMerkleRoot, buildMerkleRootFromLeaves, verifyInclusionProof, leafHash, type RemoteIdMessage, type SignedRemoteIdLog, type RemoteIdKey, } from '../../src/remoteid/index.js'; // ============================================================ // FIXTURES // ============================================================ const BASIC: RemoteIdMessage = { kind: 'basic_id', idType: F3411IdType.SERIAL_NUMBER, uaType: F3411UaType.ROTORCRAFT_HELI_OR_MULTIROTOR, uasId: 'DLV-001-ABCDEFGHIJKL', }; const LOCATION: RemoteIdMessage = { kind: 'location_vector', status: F3411OpStatus.AIRBORNE, heightType: F3411HeightType.AGL, trackDirectionDeg: 215, speedHorizontalMs: 12.5, speedVerticalMs: -1.5, latitude: 32.8505, longitude: -96.8505, pressureAltitudeM: 110, geodeticAltitudeM: 118, heightM: 45, horizontalAccuracy: 9, verticalAccuracy: 6, barometricAccuracy: 4, speedAccuracy: 2, timestampMs: Date.UTC(2026, 3, 23, 14, 30, 15, 500), timestampAccuracy: 1, }; const SELF: RemoteIdMessage = { kind: 'self_id', descriptionType: F3411DescriptionType.TEXT, description: 'BVLOS delivery route 7', }; const SYSTEM: RemoteIdMessage = { kind: 'system', operatorLocationType: F3411OperatorLocationType.LIVE_GNSS, classificationType: F3411ClassificationType.EU, operatorLatitude: 32.8500, operatorLongitude: -96.8500, operatorAltitudeM: 150, areaCount: 5, areaRadiusM: 500, areaCeilingM: 120, areaFloorM: 20, euCategory: 2, euClass: 3, timestampMs: Date.UTC(2026, 3, 23, 14, 30, 15, 0), }; const OPERATOR: RemoteIdMessage = { kind: 'operator_id', operatorIdType: F3411OperatorIdType.OPERATOR_ID, operatorId: 'FA1234567890', }; const ALL_MESSAGES: ReadonlyArray<{ name: string; msg: RemoteIdMessage }> = [ { name: 'BasicId', msg: BASIC }, { name: 'LocationVector', msg: LOCATION }, { name: 'SelfId', msg: SELF }, { name: 'System', msg: SYSTEM }, { name: 'OperatorId', msg: OPERATOR }, ]; // ============================================================ // ENCODING // ============================================================ describe('F3411 encoding', () => { for (const { name, msg } of ALL_MESSAGES) { it(`${name} encodes to exactly ${F3411_MESSAGE_SIZE} bytes`, () => { const buf = encodeRemoteIdMessage(msg); expect(buf.length).toBe(F3411_MESSAGE_SIZE); }); it(`${name} header byte has correct protocol version (low nibble = 2)`, () => { const buf = encodeRemoteIdMessage(msg); expect(buf[0]! & 0x0F).toBe(2); }); } it('encoding is deterministic for identical inputs', () => { const a = encodeRemoteIdMessage(LOCATION).toString('hex'); const b = encodeRemoteIdMessage(LOCATION).toString('hex'); expect(a).toBe(b); }); }); // ============================================================ // SIGN + VERIFY ROUND-TRIP // ============================================================ describe('signRemoteId / verifyRemoteId round-trip', () => { for (const { name, msg } of ALL_MESSAGES) { it(`${name}: sign + verify succeeds`, () => { const key = generateRemoteIdKey(); const log = signRemoteId(msg, key); expect(log.signature.length).toBe(128); // 64 bytes hex expect(log.keyId).toBe(key.keyId); expect(verifyRemoteId(log, key.publicKey)).toBe(true); }); it(`${name}: verify works with raw hex public key`, () => { const key = generateRemoteIdKey(); const log = signRemoteId(msg, key); expect(verifyRemoteId(log, key.publicKeyHex)).toBe(true); }); } }); // ============================================================ // TAMPER DETECTION // ============================================================ describe('tamper detection', () => { it('flipping one byte in frameHex → verify fails', () => { const key = generateRemoteIdKey(); const log = signRemoteId(LOCATION, key); const frameBuf = Buffer.from(log.frameHex, 'hex'); // Flip a byte inside the lat/lng region (offset 6 — middle of lat int32) frameBuf[6] = frameBuf[6]! ^ 0xFF; const tampered: SignedRemoteIdLog = { ...log, frameHex: frameBuf.toString('hex') }; expect(verifyRemoteId(tampered, key.publicKey)).toBe(false); }); it('mutating message content (without re-signing) → verify fails', () => { const key = generateRemoteIdKey(); const log = signRemoteId(OPERATOR, key); const tampered: SignedRemoteIdLog = { ...log, message: { ...(log.message as typeof OPERATOR), operatorId: 'FA9999999999', }, }; expect(verifyRemoteId(tampered, key.publicKey)).toBe(false); }); it('flipping one byte in signature → verify fails', () => { const key = generateRemoteIdKey(); const log = signRemoteId(BASIC, key); const sigBuf = Buffer.from(log.signature, 'hex'); sigBuf[10] = sigBuf[10]! ^ 0xFF; const tampered: SignedRemoteIdLog = { ...log, signature: sigBuf.toString('hex') }; expect(verifyRemoteId(tampered, key.publicKey)).toBe(false); }); it('altering timestampMs → verify fails', () => { const key = generateRemoteIdKey(); const log = signRemoteId(SYSTEM, key); const tampered: SignedRemoteIdLog = { ...log, timestampMs: log.timestampMs + 1 }; expect(verifyRemoteId(tampered, key.publicKey)).toBe(false); }); }); // ============================================================ // CROSS-KEY REJECTION // ============================================================ describe('cross-key rejection', () => { it('sign with key A, verify with key B → fails', () => { const keyA = generateRemoteIdKey(); const keyB = generateRemoteIdKey(); const log = signRemoteId(LOCATION, keyA); expect(verifyRemoteId(log, keyB.publicKey)).toBe(false); expect(verifyRemoteId(log, keyB.publicKeyHex)).toBe(false); }); it('each generated keypair has a distinct keyId', () => { const keys: RemoteIdKey[] = Array.from({ length: 5 }, () => generateRemoteIdKey()); const ids = new Set(keys.map(k => k.keyId)); expect(ids.size).toBe(5); }); }); // ============================================================ // MERKLE ROOT DETERMINISM // ============================================================ describe('Merkle root', () => { function makeLogs(key: RemoteIdKey, count: number): SignedRemoteIdLog[] { const logs: SignedRemoteIdLog[] = []; for (let i = 0; i < count; i++) { // Fix timestamp so the leaf hash is deterministic across calls. logs.push(signRemoteId(ALL_MESSAGES[i % ALL_MESSAGES.length]!.msg, key, 1_700_000_000_000 + i)); } return logs; } it('deterministic root for identical leaves', () => { const leaves = Array.from({ length: 7 }, (_, i) => 'deadbeef'.repeat(8) + i.toString(16).padStart(2, '0')); // normalize to 64 chars const normalized = leaves.map(l => l.padEnd(64, '0').slice(0, 64)); const a = buildMerkleRootFromLeaves(normalized); const b = buildMerkleRootFromLeaves(normalized); expect(a.root).toBe(b.root); expect(a.leafCount).toBe(7); }); it('different leaves → different roots', () => { const key = generateRemoteIdKey(); const logsA = makeLogs(key, 4); const logsB = makeLogs(key, 4); // Flip one log's timestamp so the leaf changes logsB[2] = { ...logsB[2]!, timestampMs: logsB[2]!.timestampMs + 1 }; const rootA = buildMerkleRoot(logsA).root; const rootB = buildMerkleRoot(logsB).root; expect(rootA).not.toBe(rootB); }); it('inclusion proof verifies for each leaf (even + odd counts)', () => { for (const count of [1, 2, 3, 5, 8, 11]) { const key = generateRemoteIdKey(); const logs = Array.from({ length: count }, (_, i) => signRemoteId( ALL_MESSAGES[i % ALL_MESSAGES.length]!.msg, key, 1_700_000_000_000 + i, ), ); const batch = buildMerkleRoot(logs); for (let i = 0; i < count; i++) { const leaf = leafHash(logs[i]!); const proof = batch.inclusion_proof(i); expect(verifyInclusionProof(leaf, proof, batch.root)).toBe(true); } } }); it('inclusion proof fails for a non-member leaf hash', () => { const key = generateRemoteIdKey(); const logs = Array.from({ length: 4 }, (_, i) => signRemoteId(BASIC, key, 1_700_000_000_000 + i), ); const batch = buildMerkleRoot(logs); const fakeLeaf = '0'.repeat(64); const proof = batch.inclusion_proof(0); expect(verifyInclusionProof(fakeLeaf, proof, batch.root)).toBe(false); }); it('buildMerkleRoot([]) throws', () => { expect(() => buildMerkleRoot([])).toThrow(); }); });