SpaceComputer

Build a verifiable satellite telemetry report

Generate a satellite pass report, sign it with Orbitport KMS, and detect tampering with an offline verifier.

A satellite pass has just ended. Our ground station has five telemetry samples and needs to send a health report to the mission team. How can the team check that the report they receive is the one we signed?

In this tutorial, we'll generate a report, sign it through Earth KMS, and build a verifier that needs only the report, its signature, and our trusted public key. Then we'll be our own adversaries: we will change a temperature reading and watch verification fail.

Of course, we will use synthetic telemetry for a fictional satellite: DEMO-SAT-1, and we're signing a ground station's report. The signature does not prove that the measurements came from a satellite or that they are accurate (for this, you'd need Space Fabric).

Before we start

Have these ready:

The application uses the Orbitport SDK and Bun's support for Node's built-in modules. We won't need a database, a satellite connection, or a model API. Allow about 25 minutes, including the SDK setup.

1. Create the project

Start in an empty directory:

mkdir telemetry-report
cd telemetry-report

Create a minimal package.json for the project:

package.json
{
  "private": true,
  "type": "module"
}

Bun runs our .ts files directly, including imports and top-level await. We don't need a separate TypeScript runner or a compilation step for our scripts.

Create a .gitignore before adding credentials or generated files:

.gitignore
node_modules/
.env
signing-key.json
trusted/
published/
receiver/

2. Turn telemetry into a report

Create telemetry.json with these five samples. The two consecutive interrupted entries describe one link interruption.

telemetry.json
[
  { "timestamp": "2026-09-21T10:00:00Z", "batteryPercent": 86, "temperatureC": 31, "linkStatus": "connected" },
  { "timestamp": "2026-09-21T10:01:00Z", "batteryPercent": 84, "temperatureC": 34, "linkStatus": "connected" },
  { "timestamp": "2026-09-21T10:02:00Z", "batteryPercent": 81, "temperatureC": 39, "linkStatus": "interrupted" },
  { "timestamp": "2026-09-21T10:03:00Z", "batteryPercent": 79, "temperatureC": 42, "linkStatus": "interrupted" },
  { "timestamp": "2026-09-21T10:04:00Z", "batteryPercent": 78, "temperatureC": 36, "linkStatus": "connected" }
]

Now create generate-report.ts. It reads the samples and writes the report we'll publish:

generate-report.ts
import { mkdir, readFile, writeFile } from 'node:fs/promises';

type TelemetrySample = {
  timestamp: string;
  batteryPercent: number;
  temperatureC: number;
  linkStatus: 'connected' | 'interrupted';
};

const samples: TelemetrySample[] = JSON.parse(await readFile('telemetry.json', 'utf8'));
const first = samples[0];
const last = samples.at(-1);
if (!first || !last) {
  throw new Error('Telemetry must contain at least one sample.');
}

const report = {
  satellite: 'DEMO-SAT-1',
  groundStation: 'Lisbon Demo Station',
  synthetic: true,
  passStart: first.timestamp,
  passEnd: last.timestamp,
  observations: samples.length,
  minimumBatteryPercent: Math.min(...samples.map((s) => s.batteryPercent)),
  maximumTemperatureC: Math.max(...samples.map((s) => s.temperatureC)),
  linkInterruptions: samples.filter((s, i) =>
    s.linkStatus === 'interrupted' && samples[i - 1]?.linkStatus !== 'interrupted'
  ).length,
};

await mkdir('published', { recursive: true });
await writeFile('published/report.json', JSON.stringify(report, null, 2) + '\n');
console.log(`Report: ${report.observations} observations, minimum battery ${report.minimumBatteryPercent}%, maximum temperature ${report.maximumTemperatureC} C, ${report.linkInterruptions} link interruption.`);

Run it:

bun generate-report.ts

You should see:

Report: 5 observations, minimum battery 78%, maximum temperature 42 C, 1 link interruption.

Open published/report.json. Notice maximumTemperatureC: 42 and synthetic: true. We now have a report, but nothing yet detects someone changing those values.

3. Connect to Orbitport

Install the Orbitport SDK from the project directory:

bun add @spacecomputer-io/orbitport-sdk-ts

Create .env and replace the placeholder with your API token:

.env
ORBITPORT_ACCESS_TOKEN=replace_with_your_api_token

Create kms.ts to configure the SDK for the Orbitport API:

kms.ts
import { OrbitportSDK } from '@spacecomputer-io/orbitport-sdk-ts';

const accessToken = process.env.ORBITPORT_ACCESS_TOKEN;
if (!accessToken) {
  throw new Error('Set ORBITPORT_ACCESS_TOKEN in .env.');
}

export const sdk = new OrbitportSDK({
  config: { accessToken, apiUrl: 'https://op.spacecomputer.io' },
});

Only key creation and signing will import this module. The signing script also retrieves the public key for the exact key version used. Our verifier will have no SDK or token dependency.

4. Create the ground station's signing key

Create create-key.ts:

create-key.ts
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { sdk } from './kms.ts';

let keyId: string;
if (existsSync('signing-key.json')) {
  const saved: { keyId: string } = JSON.parse(
    await readFile('signing-key.json', 'utf8')
  );
  keyId = saved.keyId;
  console.log('Reusing the signing key recorded in signing-key.json.');
} else {
  const { data } = await sdk.kms.createKey({
    alias: `telemetry-tutorial-${randomUUID()}`,
    keySpec: 'ECDSA_P256',
    keyUsage: 'SIGN_VERIFY',
    scheme: 'TRANSIT',
    description: 'Synthetic satellite telemetry tutorial',
  });
  const key = data.KeyMetadata;
  keyId = key.KeyId;
  await writeFile('signing-key.json', JSON.stringify({
    keyId,
  }, null, 2) + '\n', { flag: 'wx' });
}
console.log(`Signing key ready: ${keyId}`);

Run it. Bun loads the token from .env automatically:

bun create-key.ts

You should see:

Signing key ready: kms:telemetry-tutorial-...

Open signing-key.json: it contains a key identifier, not the private signing key. The private key stays in KMS.

Keep signing-key.json to reuse the key. Running the script again does not create another key.

5. Sign the report

Create sign-report.ts:

sign-report.ts
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { sdk } from './kms.ts';

type SigningKey = { keyId: string };
type PublishedSignature = {
  keyId: string;
  keyVersion: number;
  signingAlgorithm: string;
  signature: string;
};

const key: SigningKey = JSON.parse(await readFile('signing-key.json', 'utf8'));
const reportBytes = await readFile('published/report.json');
const { data } = await sdk.kms.sign({
  keyId: key.keyId,
  message: reportBytes,
  messageType: 'RAW',
  signingAlgorithm: 'ECDSA_SHA_256',
});

if (!Number.isInteger(data.KeyVersion) || data.KeyVersion < 1 || data.KeyVersion > 0xffffffff) {
  throw new Error('KMS returned an invalid signing key version.');
}

// TRANSIT returns vault:v<key-version>:<base64 DER signature>.
const match = /^vault:v(\d+):([A-Za-z0-9+/]+={0,2})$/.exec(data.Signature);
if (!match || Number(match[1]) !== data.KeyVersion) {
  throw new Error('Signature prefix does not match SignResponse.KeyVersion.');
}

const publicKey = await sdk.kms.getPublicKey({
  keyId: data.KeyId,
  version: data.KeyVersion,
});
if (publicKey.data.Version !== data.KeyVersion) {
  throw new Error('KMS returned the wrong public-key version.');
}
if (!publicKey.data.PublicKey.includes('BEGIN PUBLIC KEY')) {
  throw new Error('Expected a PEM public key.');
}

const signature: PublishedSignature = {
  keyId: data.KeyId,
  keyVersion: data.KeyVersion,
  signingAlgorithm: data.SigningAlgorithm,
  signature: match[2],
};

await mkdir('trusted', { recursive: true });
await writeFile(
  `trusted/ground-station-v${data.KeyVersion}.pem`,
  publicKey.data.PublicKey,
);
await writeFile(
  'published/report-signature.json',
  JSON.stringify(signature, null, 2) + '\n',
);
console.log(`Signed report with key version ${data.KeyVersion}.`);

We're sending the exact file bytes with messageType: 'RAW'; KMS handles SHA-256 hashing. SignResponse.KeyVersion identifies the key version that produced the signature. The script requests that historical version explicitly, checks the returned Version, and stores its PEM as trusted/ground-station-v<version>.pem. This still works if the key rotates after signing.

The mission team must obtain each versioned public key through a trusted channel before accepting reports. In this exercise, we control the trusted directory ourselves. A public key supplied by an unknown report sender is not sufficient to establish the sender's identity.

Run:

bun sign-report.ts

Expected output:

Signed report with key version 1.

The published directory now contains the report and report-signature.json, which records the detached signature and its key version. The report remains readable: signing does not encrypt it.

6. Verify as the receiving team

Create verify-report.ts. It uses Node's signature verifier, with no Orbitport calls:

verify-report.ts
import { verify } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

type PublishedSignature = {
  keyId: string;
  keyVersion: number;
  signingAlgorithm: string;
  signature: string;
};

const [reportPath, signaturePath, trustedKeyDirectory] = process.argv.slice(2);
if (!reportPath || !signaturePath || !trustedKeyDirectory) {
  console.error('Usage: bun verify-report.ts <report.json> <signature.json> <trusted-key-directory>');
  process.exit(1);
}

const signature: PublishedSignature = JSON.parse(await readFile(signaturePath, 'utf8'));
if (!Number.isInteger(signature.keyVersion) || signature.keyVersion < 1 || signature.keyVersion > 0xffffffff) {
  throw new Error('Signature record has an invalid key version.');
}
if (signature.signingAlgorithm !== 'ECDSA_SHA_256') {
  throw new Error(`Unsupported signing algorithm: ${signature.signingAlgorithm}`);
}

const trustedKeyPath = join(
  trustedKeyDirectory,
  `ground-station-v${signature.keyVersion}.pem`,
);
const valid = verify(
  'sha256',
  await readFile(reportPath),
  { key: await readFile(trustedKeyPath), dsaEncoding: 'der' },
  Buffer.from(signature.signature, 'base64'),
);

console.log(valid
  ? `VERIFIED: report matches trusted ground-station key version ${signature.keyVersion}.`
  : `FAILED: report or signature does not match trusted key version ${signature.keyVersion}.`);
process.exitCode = valid ? 0 : 1;

Check the report against the public key we saved earlier:

bun verify-report.ts published/report.json published/report-signature.json trusted

Expected output:

VERIFIED: report matches trusted ground-station key version 1.

The verifier never reads our token or contacts Orbitport. It uses the signature's recorded key version to select the matching trusted public key, so it does not silently switch to a newer key after rotation.

Let's make a receiving team's folder. Copy the public key we already trust into its own directory, then copy the incoming report and verifier:

mkdir -p receiver
cp -R trusted receiver/
cp package.json verify-report.ts published/report.json published/report-signature.json receiver/
cd receiver
bun verify-report.ts report.json report-signature.json trusted
cd ..

You should see VERIFIED again. There is no .env or SDK in the receiver folder. With Bun installed, this verification also works with the network disconnected.

7. Change a reading

Let's make the satellite appear cooler while keeping the original signature. Create tamper-report.ts:

tamper-report.ts
import { readFile, writeFile } from 'node:fs/promises';

const report: Record<string, unknown> = JSON.parse(await readFile('published/report.json', 'utf8'));
report.maximumTemperatureC = 27;
await writeFile('published/report.json', JSON.stringify(report, null, 2) + '\n');
console.log('Changed maximumTemperatureC from 42 to 27. Kept the original signature.');

Run these commands from the project root:

bun tamper-report.ts
bun verify-report.ts published/report.json published/report-signature.json trusted

You should see:

Changed maximumTemperatureC from 42 to 27. Kept the original signature.
FAILED: report or signature does not match trusted key version 1.

The verifier exits with status 1. This is the expected result: the edited report no longer matches the signed bytes. Even a whitespace-only edit would invalidate this signature.

Now regenerate the original report and verify it again, without signing it again:

bun generate-report.ts
bun verify-report.ts published/report.json published/report-signature.json trusted

The result returns to VERIFIED. Our report generator restored the exact original bytes, so the original signature works again. No additional KMS request was needed.

What we've built

We have a small ground-station workflow: sample telemetry becomes a report, KMS signs it, and a receiving team checks it independently. We also changed a reading and saw that the signature detects the alteration.

The signature establishes a relationship between these report bytes and the trusted signing key. It does not validate the sensors, establish freshness, or prevent someone replaying a previously signed report.

Keep signing-key.json and the versioned PEM files in trusted/ to repeat the lesson with the same key. If a PEM file is lost, signing another report fetches the public key for the version used by that signature. Generating, tampering with, and verifying reports are local operations; signing a new report uses one signing request and one public-key lookup. There is currently no key-deletion method in the SDK, so deleting local files does not remove the tutorial key from KMS.

For other key operations, continue with Use the KMS. For the infrastructure behind this workflow, see Orbitport architecture.

On this page