diff --git a/.gitignore b/.gitignore index f4e2c6d..f950f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules/ dist/ *.tsbuildinfo + +# Private test bundles with real location data +src/__tests__/fixtures/private-*.zip diff --git a/src/__tests__/fixtures/create-fixture.ts b/src/__tests__/fixtures/create-fixture.ts index a2c8c1a..4c682b9 100644 --- a/src/__tests__/fixtures/create-fixture.ts +++ b/src/__tests__/fixtures/create-fixture.ts @@ -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; @@ -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); } diff --git a/src/__tests__/fixtures/sample-2016-IMG_20161214_072053.zip b/src/__tests__/fixtures/sample-2016-IMG_20161214_072053.zip new file mode 100644 index 0000000..f847f49 Binary files /dev/null and b/src/__tests__/fixtures/sample-2016-IMG_20161214_072053.zip differ diff --git a/src/__tests__/parse.test.ts b/src/__tests__/parse.test.ts index c276f61..9651d7d 100644 --- a/src/__tests__/parse.test.ts +++ b/src/__tests__/parse.test.ts @@ -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', () => { @@ -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); @@ -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({ @@ -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'); + }); + }); }); diff --git a/src/create.ts b/src/create.ts index 30bca70..6de901f 100644 --- a/src/create.ts +++ b/src/create.ts @@ -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 @@ -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, diff --git a/src/index.ts b/src/index.ts index a0e7cb8..e371e5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(). */ @@ -18,6 +18,8 @@ import type { LocationProofPlugin, Runtime, LocationStamp, + UnsignedLocationStamp, + StampSigner, StampVerificationResult, } from '@decentralized-geo/astral-sdk/plugins'; @@ -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 { + 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. */ diff --git a/src/parse/bundle.ts b/src/parse/bundle.ts index 9f2b135..318b609 100644 --- a/src/parse/bundle.ts +++ b/src/parse/bundle.ts @@ -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); @@ -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; @@ -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; @@ -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 @@ -103,6 +123,7 @@ export function parseBundle(zipData: Uint8Array): ParsedBundle { metadataSignature, mediaSignature, safetyNetToken, + deviceCheckAttestation, otsProof, mediaFile, mediaFileName, diff --git a/src/parse/csv.ts b/src/parse/csv.ts index 99a9ded..dba5131 100644 --- a/src/parse/csv.ts +++ b/src/parse/csv.ts @@ -3,40 +3,89 @@ /** * ProofMode CSV metadata parser * - * ProofMode v1 outputs metadata as CSV with key-value pairs. + * Handles two CSV formats: + * 1. Vertical key-value: each row is "key,value" (synthetic / older bundles) + * 2. Horizontal header+data: first row is headers, second row is values + * (real ProofMode Android/iOS output from writeMapToCSV) */ -import type { ProofModeMetadata, ProofModeSignals } from '../types'; +import type { ProofModeMetadata } from '../types'; import { extractSignals } from './signals'; /** - * Parse ProofMode CSV metadata. - * - * Format is typically: - * ``` - * key,value - * Location.Latitude,40.7484 - * Location.Longitude,-73.9857 - * ... - * ``` + * Split a CSV line on commas, respecting that ProofMode replaces commas + * inside values with spaces (MediaWatcher.java:186). Trailing commas + * produce an empty final element which we drop. + */ +function splitCSVLine(line: string): string[] { + const parts = line.split(','); + // Drop trailing empty element from trailing comma + if (parts.length > 0 && parts[parts.length - 1].trim() === '') { + parts.pop(); + } + return parts.map(p => p.trim()); +} + +/** + * Detect whether the CSV uses horizontal (header+data) format. * - * Some bundles use colon-separated or tab-separated formats. + * Heuristic: if the first non-empty/non-comment line has more than 2 + * comma-separated fields and the next non-empty line has a similar count, + * it's horizontal. Vertical format has exactly 2 fields per line. */ -export function parseCSV(csvText: string): ProofModeMetadata { +function isHorizontalFormat(lines: string[]): boolean { + const nonEmpty = lines + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')); + + if (nonEmpty.length < 2) return false; + + const headerCount = splitCSVLine(nonEmpty[0]).length; + const dataCount = splitCSVLine(nonEmpty[1]).length; + + // Vertical format: exactly 2 columns ("key,value" or "Field,40.7") + // Horizontal format: many columns with similar counts on both rows + return headerCount > 2 && dataCount > 2; +} + +function parseHorizontal(lines: string[]): Record { + const nonEmpty = lines + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')); + + if (nonEmpty.length < 2) return {}; + + const headers = splitCSVLine(nonEmpty[0]); + const raw: Record = {}; + + // Zip each data row with headers (usually just one data row) + for (let row = 1; row < nonEmpty.length; row++) { + const values = splitCSVLine(nonEmpty[row]); + for (let i = 0; i < headers.length; i++) { + const key = headers[i]; + const value = values[i] ?? ''; + if (key && value) { + raw[key] = value; + } + } + } + + return raw; +} + +function parseVertical(lines: string[]): Record { const raw: Record = {}; - const lines = csvText.trim().split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; - // Try comma, then colon, then tab let key: string; let value: string; const commaIdx = trimmed.indexOf(','); - const colonIdx = trimmed.indexOf(':'); const tabIdx = trimmed.indexOf('\t'); + const colonIdx = trimmed.indexOf(':'); if (commaIdx > 0) { key = trimmed.substring(0, commaIdx).trim(); @@ -51,7 +100,6 @@ export function parseCSV(csvText: string): ProofModeMetadata { continue; } - // Strip quotes if (value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1); } @@ -62,6 +110,21 @@ export function parseCSV(csvText: string): ProofModeMetadata { raw[key] = value; } + return raw; +} + +/** + * Parse ProofMode CSV metadata. + * + * Supports both vertical key-value format and horizontal header+data format. + */ +export function parseCSV(csvText: string): ProofModeMetadata { + const lines = csvText.trim().split('\n'); + + const raw = isHorizontalFormat(lines) + ? parseHorizontal(lines) + : parseVertical(lines); + const signals = extractSignals(raw); return { signals, format: 'csv', fileHash: raw['FileHash'] || raw['File.Hash'] }; } diff --git a/src/parse/signals.ts b/src/parse/signals.ts index d70aa4b..e70f602 100644 --- a/src/parse/signals.ts +++ b/src/parse/signals.ts @@ -42,24 +42,45 @@ const ALIASES: Record = { provider: 'Location.Provider', cellinfo: 'CellInfo', 'wifi.mac': 'WiFi.MAC', + 'wifi mac': 'WiFi.MAC', wifimac: 'WiFi.MAC', ipv4: 'IPv4', ipv6: 'IPv6', network: 'Network', deviceid: 'DeviceID', + 'deviceid vendor': 'DeviceID.Vendor', + 'deviceid.vendor': 'DeviceID.Vendor', hardware: 'Hardware', manufacturer: 'Manufacturer', model: 'Model', proofhash: 'ProofHash', filehash: 'FileHash', 'file.hash': 'FileHash', + 'file hash sha256': 'FileHash', mimetype: 'MimeType', 'file.name': 'File.Name', filename: 'File.Name', + 'file path': 'File.Path', + 'file.path': 'File.Path', 'file.size': 'File.Size', filesize: 'File.Size', + 'file modified': 'File.Modified', + 'file.modified': 'File.Modified', datecreated: 'DateCreated', timestamp: 'Timestamp', + 'proof generated': 'ProofGenerated', + proofgenerated: 'ProofGenerated', + + // 2016-era ProofMode field names (sample-proof-1 format) + currentdatetime0gmt: 'Timestamp', + sha256: 'FileHash', + file: 'File.Name', + modified: 'File.Modified', + language: 'Language', + locale: 'Locale', + datatype: 'DataType', + networktype: 'Network', + screensize: 'ScreenSize', }; /** diff --git a/src/types.ts b/src/types.ts index e616899..8c78279 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,8 @@ export interface ParsedBundle { mediaSignature?: Uint8Array; /** Google SafetyNet/Play Integrity JWT */ safetyNetToken?: string; + /** Apple DeviceCheck attestation (iOS) */ + deviceCheckAttestation?: string; /** OpenTimestamps proof */ otsProof?: Uint8Array; /** The media file data */