feat(device-plane): enable B2 discovery ingress
This commit is contained in:
@@ -1,4 +1,18 @@
|
||||
import { createHash } from "node:crypto";
|
||||
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1";
|
||||
|
||||
const HEADER2_LENGTH = 10;
|
||||
const HEADER_START = 0xff;
|
||||
const HEADER2_GPRS_VERSION = 0x23;
|
||||
const PACKAGE_START = 0x5b;
|
||||
const PACKAGE_END = 0x5d;
|
||||
const SERVER_COMMAND_START = 0x7b;
|
||||
const SERVER_COMMAND_END = 0x7d;
|
||||
const MIN_PACKAGE_NUMBER = 0x01;
|
||||
const MAX_PACKAGE_NUMBER = 0xfb;
|
||||
const PACKET_FIXED_LENGTH = 8;
|
||||
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
|
||||
const MAX_PACKAGE_LENGTH = 64 * 1024;
|
||||
|
||||
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
schemaVersion: "nodedc.device-model-profile.v1",
|
||||
@@ -19,8 +33,15 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
preserveExistingRoutes: true,
|
||||
},
|
||||
framing: {
|
||||
status: "blocked_pending_official_specification",
|
||||
maxInitialBytes: 4096,
|
||||
status: "verified-read-only",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
headerVersion: "HEADER2_GPRS_0x23",
|
||||
headerBytes: HEADER2_LENGTH,
|
||||
maxBufferedBytes: MAX_PACKAGE_LENGTH,
|
||||
},
|
||||
acknowledgement: {
|
||||
header: "server-time-only",
|
||||
package: "package-number-only",
|
||||
},
|
||||
commandTransport: {
|
||||
status: "disabled",
|
||||
@@ -32,28 +53,154 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
},
|
||||
});
|
||||
|
||||
export function inspectUnverifiedInitialBytes(input) {
|
||||
if (!Buffer.isBuffer(input)) {
|
||||
throw new TypeError("b2_initial_bytes_buffer_required");
|
||||
export function tryParseB2Header2(input) {
|
||||
assertBuffer(input, "b2_header_buffer_required");
|
||||
if (input.length < HEADER2_LENGTH) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: HEADER2_LENGTH,
|
||||
});
|
||||
}
|
||||
if (input.length === 0) {
|
||||
throw new TypeError("b2_initial_bytes_empty");
|
||||
if (input[0] !== HEADER_START) {
|
||||
throw new TypeError("b2_header_start_invalid");
|
||||
}
|
||||
if (input.length > ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes) {
|
||||
throw new TypeError("b2_initial_bytes_limit_exceeded");
|
||||
if (input[1] !== HEADER2_GPRS_VERSION) {
|
||||
throw new TypeError("b2_header_version_unsupported");
|
||||
}
|
||||
|
||||
const identifier = input.readBigUInt64LE(2).toString(10);
|
||||
if (!/^\d{15}$/.test(identifier)) {
|
||||
throw new TypeError("b2_header_imei_invalid");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: "nodedc.device.protocol-evidence.v1",
|
||||
profileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
status: "official_framing_required",
|
||||
bytesObserved: input.length,
|
||||
contentDigest: `sha256:${createHash("sha256").update(input).digest("hex")}`,
|
||||
identifierExtracted: false,
|
||||
commandTransport: "disabled",
|
||||
status: "complete",
|
||||
bytesConsumed: HEADER2_LENGTH,
|
||||
identifier: Object.freeze({
|
||||
kind: "imei",
|
||||
value: identifier,
|
||||
trust: "claimed-not-ownership-proof",
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
transport: "tcp",
|
||||
bytesObserved: HEADER2_LENGTH,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildB2HeaderAcknowledgement(unixSeconds) {
|
||||
const timestamp = normalizeUInt32(
|
||||
unixSeconds,
|
||||
"b2_header_ack_timestamp_invalid",
|
||||
);
|
||||
const commandData = Buffer.allocUnsafe(4);
|
||||
commandData.writeUInt32LE(timestamp);
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
commandData.length,
|
||||
0x00,
|
||||
checksum(commandData),
|
||||
...commandData,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function tryParseB2Package(input) {
|
||||
assertBuffer(input, "b2_package_buffer_required");
|
||||
if (input.length === 0) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
|
||||
}
|
||||
if (input[0] !== PACKAGE_START) {
|
||||
throw new TypeError("b2_package_start_invalid");
|
||||
}
|
||||
if (input.length < 3) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
|
||||
}
|
||||
|
||||
const packageNumber = input[1];
|
||||
if (
|
||||
packageNumber < MIN_PACKAGE_NUMBER
|
||||
|| packageNumber > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_number_invalid");
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
let packetCount = 0;
|
||||
while (true) {
|
||||
if (offset >= MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (offset >= input.length) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 1,
|
||||
});
|
||||
}
|
||||
if (input[offset] === PACKAGE_END) {
|
||||
if (packetCount === 0) {
|
||||
throw new TypeError("b2_package_empty");
|
||||
}
|
||||
return Object.freeze({
|
||||
status: "complete",
|
||||
bytesConsumed: offset + 1,
|
||||
packageNumber,
|
||||
packetCount,
|
||||
});
|
||||
}
|
||||
if (input.length - offset < 3) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 3,
|
||||
});
|
||||
}
|
||||
|
||||
const dataLength = input.readUInt16LE(offset + 1);
|
||||
if (dataLength > MAX_PACKET_DATA_LENGTH) {
|
||||
throw new TypeError("b2_packet_data_length_exceeded");
|
||||
}
|
||||
const packetLength = PACKET_FIXED_LENGTH + dataLength;
|
||||
const packetEnd = offset + packetLength;
|
||||
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (input.length < packetEnd) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: packetEnd,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedChecksum = checksum(
|
||||
input.subarray(offset + 3, packetEnd - 1),
|
||||
);
|
||||
if (input[packetEnd - 1] !== expectedChecksum) {
|
||||
throw new TypeError("b2_packet_checksum_invalid");
|
||||
}
|
||||
packetCount += 1;
|
||||
offset = packetEnd;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildB2PackageAcknowledgement(packageNumber) {
|
||||
const normalized = Number(packageNumber);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < MIN_PACKAGE_NUMBER
|
||||
|| normalized > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_ack_number_invalid");
|
||||
}
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
0x00,
|
||||
normalized,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
if (profile.monitoringServerSlots !== 4) {
|
||||
throw new TypeError("b2_server_slot_count_invalid");
|
||||
@@ -64,6 +211,15 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
if (profile.serverIdentity.kind !== "imei") {
|
||||
throw new TypeError("b2_identity_kind_invalid");
|
||||
}
|
||||
if (profile.framing.status !== "verified-read-only") {
|
||||
throw new TypeError("b2_framing_must_be_verified");
|
||||
}
|
||||
if (
|
||||
profile.framing.specificationRef
|
||||
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
|
||||
) {
|
||||
throw new TypeError("b2_framing_specification_invalid");
|
||||
}
|
||||
if (profile.commandTransport.status !== "disabled") {
|
||||
throw new TypeError("b2_command_transport_must_be_disabled");
|
||||
}
|
||||
@@ -73,6 +229,30 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function checksum(input) {
|
||||
let value = 0;
|
||||
for (const byte of input) value = (value + byte) & 0xff;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUInt32(value, errorCode) {
|
||||
const normalized = Number(value);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < 0
|
||||
|| normalized > 0xffffffff
|
||||
) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertBuffer(input, errorCode) {
|
||||
if (!Buffer.isBuffer(input)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
|
||||
return value;
|
||||
|
||||
@@ -3,42 +3,118 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
ARUSNAVI_B2_MODEL_PROFILE,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
assertB2ProfileInvariant,
|
||||
inspectUnverifiedInitialBytes,
|
||||
buildB2HeaderAcknowledgement,
|
||||
buildB2PackageAcknowledgement,
|
||||
tryParseB2Header2,
|
||||
tryParseB2Package,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
test("records the official B2 route and identity evidence", () => {
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("records the official B2 route, framing and identity evidence", () => {
|
||||
assert.equal(assertB2ProfileInvariant(), true);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
);
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
|
||||
"parallel-preserved",
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed instead of guessing an IMEI from unverified bytes", () => {
|
||||
const fakeBytes = Buffer.from(
|
||||
"unverified-frame-with-fake-identifier-000000000000001",
|
||||
"utf8",
|
||||
test("parses the official HEADER2 example as a claimed IMEI", () => {
|
||||
assert.equal(
|
||||
tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
|
||||
"incomplete",
|
||||
);
|
||||
const evidence = inspectUnverifiedInitialBytes(fakeBytes);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
assert.equal(evidence.status, "official_framing_required");
|
||||
assert.equal(evidence.identifierExtracted, false);
|
||||
assert.equal(serialized.includes("000000000000001"), false);
|
||||
assert.match(evidence.contentDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
const parsed = tryParseB2Header2(specificationHeader);
|
||||
assert.equal(parsed.status, "complete");
|
||||
assert.equal(parsed.bytesConsumed, 10);
|
||||
assert.equal(parsed.identifier.kind, "imei");
|
||||
assert.equal(parsed.identifier.value, "865209039777769");
|
||||
assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof");
|
||||
assert.deepEqual(parsed.evidence, {
|
||||
transport: "tcp",
|
||||
bytesObserved: 10,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
});
|
||||
});
|
||||
|
||||
test("enforces the bounded initial frame evidence window", () => {
|
||||
test("builds the official HEADER2 acknowledgement example", () => {
|
||||
assert.equal(
|
||||
buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses and acknowledges the official package example", () => {
|
||||
assert.equal(
|
||||
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
|
||||
"incomplete",
|
||||
);
|
||||
assert.deepEqual(tryParseB2Package(specificationPackage), {
|
||||
status: "complete",
|
||||
bytesConsumed: specificationPackage.length,
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
});
|
||||
assert.equal(
|
||||
buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(),
|
||||
"7B00017D",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
|
||||
const packetData = Buffer.from([0x5d]);
|
||||
const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]);
|
||||
const checksum = (0x01 + 0x5d) & 0xff;
|
||||
const packageBytes = Buffer.from([
|
||||
0x5b,
|
||||
0x02,
|
||||
0x01,
|
||||
packetData.length,
|
||||
0x00,
|
||||
...unixTime,
|
||||
...packetData,
|
||||
checksum,
|
||||
0x5d,
|
||||
]);
|
||||
assert.deepEqual(tryParseB2Package(packageBytes), {
|
||||
status: "complete",
|
||||
bytesConsumed: packageBytes.length,
|
||||
packageNumber: 2,
|
||||
packetCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("fails closed on unsupported headers and malformed packages", () => {
|
||||
assert.throws(
|
||||
() => inspectUnverifiedInitialBytes(Buffer.alloc(0)),
|
||||
/b2_initial_bytes_empty/,
|
||||
() => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
|
||||
/b2_header_start_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => inspectUnverifiedInitialBytes(Buffer.alloc(4097)),
|
||||
/b2_initial_bytes_limit_exceeded/,
|
||||
() => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
|
||||
/b2_header_version_unsupported/,
|
||||
);
|
||||
const badChecksum = Buffer.from(specificationPackage);
|
||||
badChecksum[badChecksum.length - 2] ^= 0xff;
|
||||
assert.throws(
|
||||
() => tryParseB2Package(badChecksum),
|
||||
/b2_packet_checksum_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user