/** * ASTM F3411-22a Remote ID encoder + Ed25519 signer. * * Wire layout references the public ASTM F3411-22a specification and the * Apache-2.0 licensed opendroneid reference implementation * (https://github.com/opendroneid/opendroneid-core-c) for field byte * offsets and scaling factors. No code is copied — field layout only. * * All multi-byte numeric fields on the F3411 broadcast wire are * little-endian. Each message is 25 bytes total: 1-byte header * (MessageType high nibble | ProtocolVersion low nibble) + 24-byte * payload. * * The signer uses Node's native crypto Ed25519 (RFC 8032). Private * keys are PKCS8 DER; public keys are SPKI DER. Consumers never see * DER — only raw 32-byte public keys as hex. */ import { createPrivateKey, createPublicKey, generateKeyPairSync, sign as nodeSign, verify as nodeVerify, createHash, type KeyObject, } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { F3411MessageType, F3411_PROTOCOL_VERSION, type BasicIdMessage, type LocationVectorMessage, type SelfIdMessage, type SystemMessage, type OperatorIdMessage, type RemoteIdMessage, type SignedRemoteIdLog, } from './types.js'; // ============================================================ // CONSTANTS // ============================================================ /** F3411 message size on the wire (header + payload). */ export const F3411_MESSAGE_SIZE = 25; /** * F3411 SystemMessage epoch: 2019-01-01 00:00:00 UTC. * LocationVector message uses a different 60s-cycling convention, * handled in encodeLocationVector. */ const F3411_SYSTEM_EPOCH_MS = Date.UTC(2019, 0, 1, 0, 0, 0); // ============================================================ // KEY MANAGEMENT // ============================================================ export interface RemoteIdKey { /** Node KeyObject for the private key (for signing). */ privateKey: KeyObject; /** Node KeyObject for the public key (for verification). */ publicKey: KeyObject; /** Raw 32-byte public key, hex-encoded. */ publicKeyHex: string; /** Stable keyId = SHA-256(raw public key), hex-encoded, first 16 bytes. */ keyId: string; } /** * Raw 32-byte Ed25519 public key bytes extracted from an SPKI KeyObject. * Node's asymmetricKeyDetails doesn't expose raw bytes, so we use the JWK * export ('x' parameter is base64url-encoded raw public key per RFC 8037). */ export function rawPublicKeyBytes(publicKey: KeyObject): Buffer { const jwk = publicKey.export({ format: 'jwk' }) as { x?: string }; if (!jwk.x) throw new Error('Ed25519 public key missing JWK "x" parameter'); // base64url → Buffer return Buffer.from(jwk.x, 'base64url'); } /** Compute a stable, 16-byte hex keyId from the raw public key. */ export function keyIdFromPublicKey(publicKey: KeyObject): string { const raw = rawPublicKeyBytes(publicKey); return createHash('sha256').update(raw).digest('hex').slice(0, 32); } /** * Generate a fresh Ed25519 keypair. */ export function generateRemoteIdKey(): RemoteIdKey { const { privateKey, publicKey } = generateKeyPairSync('ed25519'); const raw = rawPublicKeyBytes(publicKey); return { privateKey, publicKey, publicKeyHex: raw.toString('hex'), keyId: keyIdFromPublicKey(publicKey), }; } /** * Load an Ed25519 keypair from `${persistDir}/remoteid-ed25519.key` and * `${persistDir}/remoteid-ed25519.pub`. If not present, generate and * persist a new pair. Persist dir defaults to GRIDSTAMP_PERSIST_DIR env * var, else ~/.gridstamp. * * File format: PKCS8 DER (private) and SPKI DER (public). These are the * standard Node key formats — re-importable with createPrivateKey/createPublicKey. */ export function loadOrCreateRemoteIdKey(persistDir?: string): RemoteIdKey { const dir = persistDir ?? process.env.GRIDSTAMP_PERSIST_DIR ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '/tmp', '.gridstamp'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const privPath = path.join(dir, 'remoteid-ed25519.key'); const pubPath = path.join(dir, 'remoteid-ed25519.pub'); if (fs.existsSync(privPath) && fs.existsSync(pubPath)) { const privDer = fs.readFileSync(privPath); const pubDer = fs.readFileSync(pubPath); const privateKey = createPrivateKey({ key: privDer, format: 'der', type: 'pkcs8' }); const publicKey = createPublicKey({ key: pubDer, format: 'der', type: 'spki' }); const raw = rawPublicKeyBytes(publicKey); return { privateKey, publicKey, publicKeyHex: raw.toString('hex'), keyId: keyIdFromPublicKey(publicKey), }; } const fresh = generateRemoteIdKey(); fs.writeFileSync( privPath, fresh.privateKey.export({ format: 'der', type: 'pkcs8' }), { mode: 0o600 }, ); fs.writeFileSync( pubPath, fresh.publicKey.export({ format: 'der', type: 'spki' }), ); return fresh; } /** * Reconstruct a KeyObject from a raw 32-byte public key (hex). * Useful on the verifier side where only the public key travels. */ export function publicKeyFromRawHex(hex: string): KeyObject { const raw = Buffer.from(hex, 'hex'); if (raw.length !== 32) throw new Error(`Ed25519 public key must be 32 bytes (got ${raw.length})`); // Node 16+ supports raw key import via JWK. return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') }, format: 'jwk', }); } // ============================================================ // ENCODERS — message → 25-byte canonical wire frame // ============================================================ /** Pack header byte: high nibble = MessageType, low nibble = ProtocolVersion. */ function headerByte(msgType: F3411MessageType): number { return ((msgType & 0x0F) << 4) | (F3411_PROTOCOL_VERSION & 0x0F); } /** Write up to N ASCII bytes into a buffer at offset; pad trailing with 0x00. */ function writeFixedAscii(buf: Buffer, offset: number, value: string, maxLen: number): void { const bytes = Buffer.from(value, 'ascii'); const len = Math.min(bytes.length, maxLen); bytes.copy(buf, offset, 0, len); for (let i = len; i < maxLen; i++) buf[offset + i] = 0; } /** Write up to N UTF-8 bytes into a buffer; pad trailing with 0x00. */ function writeFixedUtf8(buf: Buffer, offset: number, value: string, maxLen: number): void { const bytes = Buffer.from(value, 'utf8'); const len = Math.min(bytes.length, maxLen); bytes.copy(buf, offset, 0, len); for (let i = len; i < maxLen; i++) buf[offset + i] = 0; } /** * Encode BasicIdMessage per F3411-22a §5.4.2. * Byte layout (25 bytes): * [0] header * [1] (IdType << 4) | (UaType & 0x0F) * [2..21] UAS_ID (20 ASCII bytes, zero-padded) * [22..24] Reserved (0x00) */ export function encodeBasicId(msg: BasicIdMessage): Buffer { const buf = Buffer.alloc(F3411_MESSAGE_SIZE); buf[0] = headerByte(F3411MessageType.BASIC_ID); buf[1] = ((msg.idType & 0x0F) << 4) | (msg.uaType & 0x0F); writeFixedAscii(buf, 2, msg.uasId, 20); // bytes 22..24 are reserved — left as zero return buf; } /** * Encode LocationVectorMessage per F3411-22a §5.4.3. * * Lat/lng are signed int32 at 1e-7 deg/LSB. Altitudes are int16 with * 0.5 m/LSB plus a -1000 m offset: encoded = (metres + 1000) / 0.5. * Horizontal speed is uint8 at 0.25 m/s/LSB up to 63.75 m/s; above that * a mult=1 flag switches to 0.75 m/s + 64*0.25. Vertical speed is int8 * at 0.5 m/s/LSB. Track direction is uint8 degrees with a direction-segment * flag (EW bit) for [180..359] — we store low 8 bits and set flag in * status byte. Timestamp within the Location message is "tenths of a * second from the current UTC hour" (F3411 §5.4.3) — we convert from ms. * * NOTE: F3411 packs the direction high bit, speed multiplier, and * height-type into the `Status` byte along with OpStatus in the low * nibble. The layout in bytes 1..24 below is the canonical 22a encoding. */ export function encodeLocationVector(msg: LocationVectorMessage): Buffer { const buf = Buffer.alloc(F3411_MESSAGE_SIZE); buf[0] = headerByte(F3411MessageType.LOCATION_VECTOR); // Direction flag (E/W) — true when track is >=180. We store direction // modulo 180 in the direction byte, then set the flag in the status byte. const dirRaw = ((msg.trackDirectionDeg % 360) + 360) % 360; const ewFlag = dirRaw >= 180 ? 1 : 0; const dirEncoded = ewFlag ? dirRaw - 180 : dirRaw; // 0..179 // Horizontal speed multiplier: mult=0 scale 0.25 m/s, mult=1 scale 0.75 m/s. let speedHMult = 0; let speedHEnc: number; if (msg.speedHorizontalMs < 63.75) { speedHEnc = Math.max(0, Math.min(254, Math.round(msg.speedHorizontalMs / 0.25))); speedHMult = 0; } else { speedHEnc = Math.max(0, Math.min(254, Math.round((msg.speedHorizontalMs - (255 * 0.25)) / 0.75))); speedHMult = 1; } // Status byte packs OpStatus (4 bits), reserved (1 bit), heightType (1 bit), // EW direction flag (1 bit), speed multiplier (1 bit). const statusByte = ((msg.status & 0x0F) << 4) | ((msg.heightType & 0x01) << 2) | ((ewFlag & 0x01) << 1) | (speedHMult & 0x01); buf[1] = statusByte; // [2] Track direction 0..179 buf[2] = dirEncoded & 0xFF; // [3] Horizontal speed encoded buf[3] = speedHEnc & 0xFF; // [4] Vertical speed int8 at 0.5 m/s/LSB const vsEnc = Math.max(-127, Math.min(127, Math.round(msg.speedVerticalMs / 0.5))); buf.writeInt8(vsEnc, 4); // [5..8] Latitude int32 LE, 1e-7 deg/LSB buf.writeInt32LE(Math.round(msg.latitude * 1e7), 5); // [9..12] Longitude int32 LE, 1e-7 deg/LSB buf.writeInt32LE(Math.round(msg.longitude * 1e7), 9); // [13..14] Pressure altitude uint16 LE, 0.5 m/LSB with -1000 offset buf.writeUInt16LE(encodeAlt(msg.pressureAltitudeM), 13); // [15..16] Geodetic altitude uint16 LE, 0.5 m/LSB with -1000 offset buf.writeUInt16LE(encodeAlt(msg.geodeticAltitudeM), 15); // [17..18] Height uint16 LE, 0.5 m/LSB with -1000 offset buf.writeUInt16LE(encodeAlt(msg.heightM), 17); // [19] Vertical accuracy (high nibble) | Horizontal accuracy (low nibble) buf[19] = ((msg.verticalAccuracy & 0x0F) << 4) | (msg.horizontalAccuracy & 0x0F); // [20] Barometric accuracy (high nibble) | Speed accuracy (low nibble) buf[20] = ((msg.barometricAccuracy & 0x0F) << 4) | (msg.speedAccuracy & 0x0F); // [21..22] Timestamp uint16 LE — "tenths of a second elapsed in the // current UTC hour" per F3411 §5.4.3. 0..36000 range. const date = new Date(msg.timestampMs); const msSinceHour = date.getUTCMinutes() * 60_000 + date.getUTCSeconds() * 1_000 + date.getUTCMilliseconds(); const tenths = Math.min(36000, Math.floor(msSinceHour / 100)); buf.writeUInt16LE(tenths, 21); // [23] Timestamp accuracy (low nibble). High nibble reserved. buf[23] = msg.timestampAccuracy & 0x0F; // [24] Reserved buf[24] = 0; return buf; } /** Encode an altitude metres value into the F3411 uint16 bucket. */ function encodeAlt(m: number): number { const v = Math.round((m + 1000) / 0.5); return Math.max(0, Math.min(0xFFFF, v)); } /** * Encode SelfIdMessage per F3411-22a §5.4.6. * Byte layout: * [0] header * [1] description type * [2..24] 23-byte UTF-8 description (zero padded) */ export function encodeSelfId(msg: SelfIdMessage): Buffer { const buf = Buffer.alloc(F3411_MESSAGE_SIZE); buf[0] = headerByte(F3411MessageType.SELF_ID); buf[1] = msg.descriptionType & 0xFF; writeFixedUtf8(buf, 2, msg.description, 23); return buf; } /** * Encode SystemMessage per F3411-22a §5.4.7. * Byte layout: * [0] header * [1] flags: reserved(3) | classificationType(3) | operatorLocationType(2) * [2..5] Operator latitude int32 LE, 1e-7 deg/LSB * [6..9] Operator longitude int32 LE, 1e-7 deg/LSB * [10..11] Area count uint16 LE * [12] Area radius uint8 (metres × 10 per spec; we store m directly, clamped) * [13..14] Area ceiling uint16 LE (altitude encoded) * [15..16] Area floor uint16 LE (altitude encoded) * [17] EU classification: category(4) | class(4) * [18..19] Operator altitude uint16 LE (altitude encoded) * [20..23] Timestamp uint32 LE — seconds since 2019-01-01 UTC * [24] Reserved */ export function encodeSystem(msg: SystemMessage): Buffer { const buf = Buffer.alloc(F3411_MESSAGE_SIZE); buf[0] = headerByte(F3411MessageType.SYSTEM); buf[1] = ((msg.classificationType & 0x07) << 2) | (msg.operatorLocationType & 0x03); buf.writeInt32LE(Math.round(msg.operatorLatitude * 1e7), 2); buf.writeInt32LE(Math.round(msg.operatorLongitude * 1e7), 6); buf.writeUInt16LE(Math.max(0, Math.min(0xFFFF, msg.areaCount)), 10); buf[12] = Math.max(0, Math.min(255, Math.round(msg.areaRadiusM / 10))); buf.writeUInt16LE(encodeAlt(msg.areaCeilingM), 13); buf.writeUInt16LE(encodeAlt(msg.areaFloorM), 15); buf[17] = ((msg.euCategory & 0x0F) << 4) | (msg.euClass & 0x0F); buf.writeUInt16LE(encodeAlt(msg.operatorAltitudeM), 18); const secsSinceEpoch = Math.max( 0, Math.floor((msg.timestampMs - F3411_SYSTEM_EPOCH_MS) / 1000), ); buf.writeUInt32LE(secsSinceEpoch >>> 0, 20); buf[24] = 0; return buf; } /** * Encode OperatorIdMessage per F3411-22a §5.4.8. * Byte layout: * [0] header * [1] operator ID type * [2..21] 20-byte ASCII operator ID (zero padded) * [22..24] Reserved */ export function encodeOperatorId(msg: OperatorIdMessage): Buffer { const buf = Buffer.alloc(F3411_MESSAGE_SIZE); buf[0] = headerByte(F3411MessageType.OPERATOR_ID); buf[1] = msg.operatorIdType & 0xFF; writeFixedAscii(buf, 2, msg.operatorId, 20); return buf; } /** Dispatch to the per-type encoder. */ export function encodeRemoteIdMessage(msg: RemoteIdMessage): Buffer { switch (msg.kind) { case 'basic_id': return encodeBasicId(msg); case 'location_vector': return encodeLocationVector(msg); case 'self_id': return encodeSelfId(msg); case 'system': return encodeSystem(msg); case 'operator_id': return encodeOperatorId(msg); } } // ============================================================ // SIGN / VERIFY // ============================================================ /** * Build the canonical byte string to be signed. * Layout: frame (25 bytes) || uint64BE timestampMs || keyId ASCII bytes. * Including timestamp + keyId in the signed blob prevents cross-key * replay and timestamp-stripping attacks. */ function buildSigningBlob(frame: Buffer, timestampMs: number, keyId: string): Buffer { if (frame.length !== F3411_MESSAGE_SIZE) { throw new Error(`F3411 frame must be exactly ${F3411_MESSAGE_SIZE} bytes`); } const header = Buffer.alloc(8); // writeBigUInt64BE for safety with large ms values header.writeBigUInt64BE(BigInt(timestampMs), 0); const kid = Buffer.from(keyId, 'ascii'); return Buffer.concat([frame, header, kid]); } /** * Sign a Remote ID message with an Ed25519 key. Returns a SignedRemoteIdLog * containing the canonical frame, signature, keyId, and timestamp. */ export function signRemoteId( msg: RemoteIdMessage, key: RemoteIdKey, now?: number, ): SignedRemoteIdLog { const frame = encodeRemoteIdMessage(msg); const timestampMs = now ?? Date.now(); const blob = buildSigningBlob(frame, timestampMs, key.keyId); // Ed25519 does not use a digest algorithm — pass null. const signature = nodeSign(null, blob, key.privateKey).toString('hex'); return { message: msg, frameHex: frame.toString('hex'), signature, keyId: key.keyId, timestampMs, }; } /** * Verify a signed Remote ID log. Returns true iff the signature is valid * for the given public key and the frame encoding still matches the * decoded message. `pubKey` can be a KeyObject or a raw 32-byte hex string. */ export function verifyRemoteId( log: SignedRemoteIdLog, pubKey: KeyObject | string, ): boolean { const frame = Buffer.from(log.frameHex, 'hex'); if (frame.length !== F3411_MESSAGE_SIZE) return false; // Re-derive canonical frame from the decoded message and compare bytes. // This closes the attack where an adversary swaps message content but // keeps the original frameHex + signature. let expected: Buffer; try { expected = encodeRemoteIdMessage(log.message); } catch { return false; } if (!expected.equals(frame)) return false; const publicKey = typeof pubKey === 'string' ? publicKeyFromRawHex(pubKey) : pubKey; const blob = buildSigningBlob(frame, log.timestampMs, log.keyId); const sig = Buffer.from(log.signature, 'hex'); if (sig.length !== 64) return false; try { return nodeVerify(null, blob, publicKey, sig); } catch { return false; } }