Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
node_modules/
dist/
*.tsbuildinfo

# Private test bundles with real location data
src/__tests__/fixtures/private-*.zip
10 changes: 10 additions & 0 deletions src/__tests__/fixtures/create-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function createSyntheticBundle(options: {
includePublicKey?: boolean;
includeSafetyNet?: boolean;
includeOTS?: boolean;
includeDeviceCheck?: boolean;
includeTxtFile?: boolean;
} = {}): Uint8Array {
const lat = options.lat ?? 40.7484;
const lon = options.lon ?? -73.9857;
Expand Down Expand Up @@ -97,5 +99,13 @@ export function createSyntheticBundle(options: {
files[`${fileHash}.ots`] = encoder.encode('fake-ots-proof');
}

if (options.includeDeviceCheck) {
files[`${fileHash}.devicecheck`] = encoder.encode('device-check-token-data');
}

if (options.includeTxtFile) {
files['HowToVerifyProofData.txt'] = encoder.encode('Documentation about verification');
}

return zipSync(files);
}
Binary file not shown.
161 changes: 159 additions & 2 deletions src/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
// Copyright © 2026 Sophia Systems Corporation

import * as fs from 'fs';
import * as path from 'path';
import { parseBundle } from '../parse';
import { createStampFromBundle } from '../create';
import { parseCSV } from '../parse/csv';
import { parseJSON } from '../parse/json';
import { createSyntheticBundle } from './fixtures/create-fixture';

const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const PRIVATE_BUNDLE = path.join(FIXTURES_DIR, 'private-837e10d85e25f6e2-2026-02-12-22-38-11GMT.zip');
const SAMPLE_2016_BUNDLE = path.join(FIXTURES_DIR, 'sample-2016-IMG_20161214_072053.zip');
const hasPrivateBundle = fs.existsSync(PRIVATE_BUNDLE);
const hasSample2016 = fs.existsSync(SAMPLE_2016_BUNDLE);

describe('ProofMode parser', () => {
describe('parseBundle', () => {
it('parses a synthetic ProofMode ZIP bundle', () => {
Expand Down Expand Up @@ -76,17 +85,58 @@ describe('ProofMode parser', () => {
expect(s['Manufacturer']).toBeDefined();
expect(s['Model']).toBeDefined();
});

it('skips .txt documentation files', () => {
const zipData = createSyntheticBundle({ includeTxtFile: true });
const bundle = parseBundle(zipData);
// .txt file should not become the media file
expect(bundle.mediaFileName).toBeUndefined();
});

it('extracts DeviceCheck attestation', () => {
const zipData = createSyntheticBundle({ includeDeviceCheck: true });
const bundle = parseBundle(zipData);
expect(bundle.deviceCheckAttestation).toBeTruthy();
expect(bundle.deviceCheckAttestation).toContain('device-check-token');
});
});

describe('parseCSV', () => {
it('parses comma-separated format', () => {
it('parses vertical key-value format', () => {
const csv = 'Location.Latitude,40.7484\nLocation.Longitude,-73.9857';
const result = parseCSV(csv);
expect(result.signals['Location.Latitude']).toBe(40.7484);
expect(result.signals['Location.Longitude']).toBe(-73.9857);
});

it('skips header row', () => {
it('parses horizontal header+data format', () => {
const csv = [
'Location.Latitude,Location.Longitude,Location.Accuracy,Location.Provider,',
'40.7484,-73.9857,10,gps,',
].join('\n');
const result = parseCSV(csv);
expect(result.signals['Location.Latitude']).toBe(40.7484);
expect(result.signals['Location.Longitude']).toBe(-73.9857);
expect(result.signals['Location.Accuracy']).toBe(10);
expect(result.signals['Location.Provider']).toBe('gps');
});

it('handles horizontal format with many columns', () => {
const csv = [
'Location.Latitude,Location.Longitude,Location.Altitude,Location.Speed,Location.Bearing,Location.Time,Network,Model,',
'34.0522,-118.2437,71.5,0,180,1700000000000,WiFi,iPhone15 3,',
].join('\n');
const result = parseCSV(csv);
expect(result.signals['Location.Latitude']).toBe(34.0522);
expect(result.signals['Location.Longitude']).toBe(-118.2437);
expect(result.signals['Location.Altitude']).toBe(71.5);
expect(result.signals['Location.Time']).toBe(1700000000000);
expect(result.signals['Network']).toBe('WiFi');
// Commas in values are replaced with spaces by ProofMode
expect(result.signals['Model']).toBe('iPhone15 3');
});

it('skips header row in vertical format', () => {
const csv = 'key,value\nLocation.Latitude,40.7484';
const result = parseCSV(csv);
expect(result.signals['Location.Latitude']).toBe(40.7484);
Expand All @@ -106,6 +156,22 @@ describe('ProofMode parser', () => {
});
});

describe('signal aliases', () => {
it('normalizes iOS-style field names', () => {
const csv = [
'Wifi MAC,DeviceID Vendor,File Path,File Hash SHA256,File Modified,Proof Generated,',
'AA:BB:CC:DD:EE:FF,VENDOR-123,/path/to/file,abcd1234,2024-01-01,2024-01-01T12:00:00Z,',
].join('\n');
const result = parseCSV(csv);
expect(result.signals['WiFi.MAC']).toBe('AA:BB:CC:DD:EE:FF');
expect(result.signals['DeviceID.Vendor']).toBe('VENDOR-123');
expect(result.signals['File.Path']).toBe('/path/to/file');
expect(result.signals['FileHash']).toBe('abcd1234');
expect(result.signals['File.Modified']).toBe('2024-01-01');
expect(result.signals['ProofGenerated']).toBe('2024-01-01T12:00:00Z');
});
});

describe('parseJSON', () => {
it('parses flat JSON', () => {
const json = JSON.stringify({
Expand All @@ -127,4 +193,95 @@ describe('ProofMode parser', () => {
expect(result.signals['Location.Latitude']).toBe(40.7484);
});
});

// Real bundle tests — run only when fixture files are present
describe('real iOS bundle (private)', () => {
const skipMsg = 'private bundle not available';

(hasPrivateBundle ? it : it.skip)('parses bundle without errors', () => {
const zip = fs.readFileSync(PRIVATE_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));

expect(bundle.metadata.format).toBe('csv');
expect(bundle.files.length).toBeGreaterThanOrEqual(5);
});

(hasPrivateBundle ? it : it.skip)('extracts iOS-specific artifacts', () => {
const zip = fs.readFileSync(PRIVATE_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));

expect(bundle.publicKey).toContain('BEGIN PGP PUBLIC KEY BLOCK');
expect(bundle.deviceCheckAttestation).toBeTruthy();
expect(bundle.otsProof).toBeTruthy();
expect(bundle.metadataSignature).toBeTruthy();
expect(bundle.mediaFile).toBeTruthy();
expect(bundle.mediaFileName).toMatch(/\.jpg$/i);
});

(hasPrivateBundle ? it : it.skip)('extracts location signals from horizontal CSV', () => {
const zip = fs.readFileSync(PRIVATE_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));
const s = bundle.metadata.signals;

// Verify signals exist (don't assert specific coords — private data)
expect(typeof s['Location.Latitude']).toBe('number');
expect(typeof s['Location.Longitude']).toBe('number');
expect(s['Location.Latitude']).toBeGreaterThanOrEqual(-90);
expect(s['Location.Latitude']).toBeLessThanOrEqual(90);
expect(s['Location.Longitude']).toBeGreaterThanOrEqual(-180);
expect(s['Location.Longitude']).toBeLessThanOrEqual(180);
});

(hasPrivateBundle ? it : it.skip)('creates a valid UnsignedLocationStamp', () => {
const zip = fs.readFileSync(PRIVATE_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));
const stamp = createStampFromBundle(bundle, '0.1.0');

expect(stamp.lpVersion).toBe('0.2');
expect(stamp.plugin).toBe('proofmode');
expect(stamp.srs).toBe('http://www.opengis.net/def/crs/OGC/1.3/CRS84');
const loc = stamp.location as { type: string; coordinates: number[] };
expect(loc.type).toBe('Point');
expect(loc.coordinates).toHaveLength(2);
expect(stamp.temporalFootprint.start).toBeGreaterThan(0);
expect(stamp.temporalFootprint.end).toBeGreaterThan(stamp.temporalFootprint.start);
expect(stamp.signals['DeviceCheck.Attestation']).toBeTruthy();
expect(stamp.signals['HasPGPKey']).toBe(true);
expect(stamp.signals['HasOTS']).toBe(true);
});
});

describe('real 2016 Android sample', () => {
(hasSample2016 ? it : it.skip)('parses 2016 sample bundle', () => {
const zip = fs.readFileSync(SAMPLE_2016_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));

expect(bundle.metadata.format).toBe('csv');
expect(bundle.mediaFile).toBeTruthy();
expect(bundle.mediaFileName).toMatch(/\.jpg$/i);
expect(bundle.metadataSignature).toBeTruthy();
});

(hasSample2016 ? it : it.skip)('normalizes 2016-era field names', () => {
const zip = fs.readFileSync(SAMPLE_2016_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));
const s = bundle.metadata.signals;

// 2016 format uses legacy names that should alias to canonical form
expect(s['Timestamp']).toBeDefined(); // from CurrentDateTime0GMT
expect(s['FileHash']).toBeDefined(); // from SHA256
expect(s['File.Name']).toBeDefined(); // from File
expect(s['Network']).toBeDefined(); // from NetworkType
expect(s['File.Modified']).toBeDefined(); // from Modified
});

(hasSample2016 ? it : it.skip)('throws when creating stamp — no GPS in 2016 sample', () => {
const zip = fs.readFileSync(SAMPLE_2016_BUNDLE);
const bundle = parseBundle(new Uint8Array(zip));

// 2016 samples lack Location.Latitude/Longitude — stamp creation fails
expect(() => createStampFromBundle(bundle, '0.1.0'))
.toThrow('missing Location.Latitude or Location.Longitude');
});
});
});
18 changes: 16 additions & 2 deletions src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,28 @@ export function createStampFromBundle(bundle: ParsedBundle, pluginVersion: strin
allSignals['SafetyNet.JWT'] = bundle.safetyNetToken;
}

// Add DeviceCheck info if present (iOS)
if (bundle.deviceCheckAttestation) {
allSignals['DeviceCheck.Attestation'] = bundle.deviceCheckAttestation;
}

// Note presence of OTS proof
if (bundle.otsProof) {
allSignals['HasOTS'] = true;
}

// Note presence of PGP key
// PGP provenance: key presence flag + actual key and signature as evidence
if (bundle.publicKey) {
allSignals['HasPGPKey'] = true;
allSignals['PGP.PublicKey'] = bundle.publicKey;
}

if (bundle.metadataSignature && bundle.metadataSignature.length > 0) {
let binary = '';
for (let i = 0; i < bundle.metadataSignature.length; i++) {
binary += String.fromCharCode(bundle.metadataSignature[i]);
}
allSignals['PGP.MetadataSignature'] = btoa(binary);
}

// File hash for integrity
Expand All @@ -71,7 +85,7 @@ export function createStampFromBundle(bundle: ParsedBundle, pluginVersion: strin
type: 'Point',
coordinates: [lon, lat], // GeoJSON: [lon, lat]
},
srs: 'EPSG:4326',
srs: 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
temporalFootprint: {
start: startTime,
end: endTime,
Expand Down
25 changes: 23 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* - Destructured: Extract essentials only (~15-20KB), discard media files
* - Un-destructured: Preserve full original bundle for forensic integrity
*
* The plugin implements verify and create. It does NOT implement
* collect or sign because ProofMode handles those internally on the device.
* The plugin implements verify, create, and sign. It does NOT implement
* collect because ProofMode handles that internally on the device.
*
* Evaluation (spatial/temporal scoring) is handled by the SDK's ProofsModule.verify().
*/
Expand All @@ -18,6 +18,8 @@ import type {
LocationProofPlugin,
Runtime,
LocationStamp,
UnsignedLocationStamp,
StampSigner,
StampVerificationResult,
} from '@decentralized-geo/astral-sdk/plugins';

Expand Down Expand Up @@ -53,6 +55,25 @@ export class ProofModePlugin implements LocationProofPlugin {
return createStampFromBundle(bundle, this.version);
}

/**
* Cryptographically sign an unsigned stamp.
*
* ProofModePlugin doesn't manage keys — a StampSigner is required.
*/
async sign(stamp: UnsignedLocationStamp, signer: StampSigner): Promise<LocationStamp> {
const data = JSON.stringify(stamp);
const sigValue = await signer.sign(data);
return {
...stamp,
signatures: [{
signer: signer.signer,
algorithm: signer.algorithm,
value: sigValue,
timestamp: Math.floor(Date.now() / 1000),
}],
};
}

/**
* Verify a ProofMode stamp's internal validity.
*/
Expand Down
29 changes: 25 additions & 4 deletions src/parse/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ import { parseJSON } from './json';
* pubkey.asc — PGP public key
* ```
*/
/** Known media file extensions (lowercase, with leading dot). */
const MEDIA_EXTENSIONS = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.heif',
'.tiff', '.tif', '.avif',
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp',
'.mp3', '.wav', '.aac', '.ogg', '.m4a',
]);

function isMediaFile(name: string): boolean {
const dot = name.lastIndexOf('.');
if (dot < 0) return false;
return MEDIA_EXTENSIONS.has(name.substring(dot).toLowerCase());
}

export function parseBundle(zipData: Uint8Array): ParsedBundle {
const entries = unzipSync(zipData);

Expand All @@ -36,6 +50,7 @@ export function parseBundle(zipData: Uint8Array): ParsedBundle {
let metadataSignature: Uint8Array | undefined;
let mediaSignature: Uint8Array | undefined;
let safetyNetToken: string | undefined;
let deviceCheckAttestation: string | undefined;
let otsProof: Uint8Array | undefined;
let mediaFile: Uint8Array | undefined;
let mediaFileName: string | undefined;
Expand All @@ -46,12 +61,13 @@ export function parseBundle(zipData: Uint8Array): ParsedBundle {
files.push({ name, data });

const lower = name.toLowerCase();
const baseName = lower.split('/').pop() ?? lower;

if (lower.endsWith('.proof.csv')) {
csvData = data;
} else if (lower.endsWith('.proof.json')) {
jsonData = data;
} else if (lower === 'pubkey.asc' || lower.endsWith('/pubkey.asc')) {
} else if (baseName === 'pubkey.asc') {
publicKey = decoder.decode(data);
} else if (lower.endsWith('.proof.csv.asc')) {
metadataSignature = data;
Expand All @@ -62,15 +78,19 @@ export function parseBundle(zipData: Uint8Array): ParsedBundle {
}
} else if (lower.endsWith('.gst')) {
safetyNetToken = decoder.decode(data);
} else if (lower.endsWith('.devicecheck')) {
deviceCheckAttestation = decoder.decode(data);
} else if (lower.endsWith('.ots')) {
otsProof = data;
} else if (lower.endsWith('.asc') && !lower.includes('proof') && lower !== 'pubkey.asc') {
} else if (lower.endsWith('.txt')) {
// Documentation files (e.g. HowToVerifyProofData.txt) — skip
} else if (lower.endsWith('.asc') && !lower.includes('proof') && baseName !== 'pubkey.asc') {
mediaSignature = data;
} else if (!lower.endsWith('.asc') && !lower.endsWith('.gst') && !lower.endsWith('.ots')) {
// Assume any remaining file is the media file
} else if (isMediaFile(name)) {
mediaFile = data;
mediaFileName = name;
}
// Unknown extensions are silently ignored — they're still in files[]
}

// Parse metadata from CSV or JSON
Expand Down Expand Up @@ -103,6 +123,7 @@ export function parseBundle(zipData: Uint8Array): ParsedBundle {
metadataSignature,
mediaSignature,
safetyNetToken,
deviceCheckAttestation,
otsProof,
mediaFile,
mediaFileName,
Expand Down
Loading