@nextera.one/tps-standard 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -49
- package/dist/index.d.ts +366 -2
- package/dist/index.js +724 -1
- package/dist/src/index.js +693 -0
- package/dist/test/src/index.js +960 -0
- package/dist/test/test/persian-calendar.test.js +488 -0
- package/dist/test/test/tps-uid.test.js +295 -0
- package/dist/test/tps-uid.test.js +240 -0
- package/package.json +3 -2
- package/src/index.ts +1031 -2
package/src/index.ts
CHANGED
|
@@ -42,12 +42,63 @@ export interface TPSComponents {
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Interface for Calendar Driver plugins.
|
|
45
|
-
* Implementations
|
|
45
|
+
* Implementations provide conversion logic to/from Gregorian and support for
|
|
46
|
+
* external calendar libraries.
|
|
47
|
+
*
|
|
48
|
+
* @example Using a driver to parse a Hijri date string
|
|
49
|
+
* ```ts
|
|
50
|
+
* const driver = TPS.getDriver('hij');
|
|
51
|
+
* if (driver?.parseDate) {
|
|
52
|
+
* const components = driver.parseDate('1447-07-21');
|
|
53
|
+
* const gregDate = driver.toGregorian(components);
|
|
54
|
+
* const tpsString = TPS.fromDate(gregDate, 'hij');
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @example Wrapping an external library (moment-hijri)
|
|
59
|
+
* ```ts
|
|
60
|
+
* import moment from 'moment-hijri';
|
|
61
|
+
*
|
|
62
|
+
* class HijriDriver implements CalendarDriver {
|
|
63
|
+
* readonly code = 'hij';
|
|
64
|
+
*
|
|
65
|
+
* parseDate(input: string, format?: string): Partial<TPSComponents> {
|
|
66
|
+
* const m = moment(input, format || 'iYYYY-iMM-iDD');
|
|
67
|
+
* return {
|
|
68
|
+
* calendar: 'hij',
|
|
69
|
+
* year: m.iYear(),
|
|
70
|
+
* month: m.iMonth() + 1,
|
|
71
|
+
* day: m.iDate()
|
|
72
|
+
* };
|
|
73
|
+
* }
|
|
74
|
+
*
|
|
75
|
+
* fromGregorian(date: Date): Partial<TPSComponents> {
|
|
76
|
+
* const m = moment(date);
|
|
77
|
+
* return {
|
|
78
|
+
* calendar: 'hij',
|
|
79
|
+
* year: m.iYear(),
|
|
80
|
+
* month: m.iMonth() + 1,
|
|
81
|
+
* day: m.iDate(),
|
|
82
|
+
* hour: m.hour(),
|
|
83
|
+
* minute: m.minute(),
|
|
84
|
+
* second: m.second()
|
|
85
|
+
* };
|
|
86
|
+
* }
|
|
87
|
+
*
|
|
88
|
+
* // ... other methods
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
46
91
|
*/
|
|
47
92
|
export interface CalendarDriver {
|
|
48
93
|
/** The calendar code this driver handles (e.g., 'hij', 'jul'). */
|
|
49
94
|
readonly code: CalendarCode;
|
|
50
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Human-readable name for this calendar (optional).
|
|
98
|
+
* @example "Hijri (Islamic)"
|
|
99
|
+
*/
|
|
100
|
+
readonly name?: string;
|
|
101
|
+
|
|
51
102
|
/**
|
|
52
103
|
* Converts a Gregorian Date to this calendar's components.
|
|
53
104
|
* @param date - The Gregorian Date object.
|
|
@@ -65,9 +116,94 @@ export interface CalendarDriver {
|
|
|
65
116
|
/**
|
|
66
117
|
* Generates a TPS time string for this calendar from a Date.
|
|
67
118
|
* @param date - The Gregorian Date object.
|
|
68
|
-
* @returns A TPS time string (e.g., "T:hij.
|
|
119
|
+
* @returns A TPS time string (e.g., "T:hij.y1447.M07.d21...").
|
|
69
120
|
*/
|
|
70
121
|
fromDate(date: Date): string;
|
|
122
|
+
|
|
123
|
+
// --- NEW ENHANCED METHODS (Optional) ---
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse a calendar-specific date string into TPS components.
|
|
127
|
+
* This allows drivers to handle native date formats from external libraries.
|
|
128
|
+
*
|
|
129
|
+
* @param input - Date string in calendar-native format (e.g., '1447-07-21' for Hijri)
|
|
130
|
+
* @param format - Optional format string (driver-specific, e.g., 'iYYYY-iMM-iDD')
|
|
131
|
+
* @returns Partial TPS components
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* // Hijri driver
|
|
136
|
+
* driver.parseDate('1447-07-21'); // → { year: 1447, month: 7, day: 21, calendar: 'hij' }
|
|
137
|
+
*
|
|
138
|
+
* // With time
|
|
139
|
+
* driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
parseDate?(input: string, format?: string): Partial<TPSComponents>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Format TPS components to a calendar-specific date string.
|
|
146
|
+
* Inverse of parseDate().
|
|
147
|
+
*
|
|
148
|
+
* @param components - TPS components to format
|
|
149
|
+
* @param format - Optional format string (driver-specific)
|
|
150
|
+
* @returns Formatted date string in calendar-native format
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* driver.format({ year: 1447, month: 7, day: 21 }); // → '1447-07-21'
|
|
155
|
+
* driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
format?(components: Partial<TPSComponents>, format?: string): string;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate a calendar-specific date string or components.
|
|
162
|
+
*
|
|
163
|
+
* @param input - Date string or components to validate
|
|
164
|
+
* @returns true if valid for this calendar
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* driver.validate('1447-13-01'); // → false (month 13 invalid)
|
|
169
|
+
* driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
validate?(input: string | Partial<TPSComponents>): boolean;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get calendar metadata (month names, day names, etc.).
|
|
176
|
+
* Useful for UI rendering.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* driver.getMetadata().monthNames
|
|
181
|
+
* // → ['Muharram', 'Safar', 'Rabi I', ...]
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
getMetadata?(): CalendarMetadata;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Metadata about a calendar system.
|
|
189
|
+
*/
|
|
190
|
+
export interface CalendarMetadata {
|
|
191
|
+
/** Human-readable calendar name */
|
|
192
|
+
name: string;
|
|
193
|
+
/** Month names in order (1-12 or 1-13) */
|
|
194
|
+
monthNames?: string[];
|
|
195
|
+
/** Short month names */
|
|
196
|
+
monthNamesShort?: string[];
|
|
197
|
+
/** Day of week names (Sunday=0 or locale-specific) */
|
|
198
|
+
dayNames?: string[];
|
|
199
|
+
/** Short day names */
|
|
200
|
+
dayNamesShort?: string[];
|
|
201
|
+
/** Whether this calendar is lunar-based */
|
|
202
|
+
isLunar?: boolean;
|
|
203
|
+
/** Number of months per year */
|
|
204
|
+
monthsPerYear?: number;
|
|
205
|
+
/** Epoch year (for reference) */
|
|
206
|
+
epochYear?: number;
|
|
71
207
|
}
|
|
72
208
|
|
|
73
209
|
export class TPS {
|
|
@@ -268,6 +404,127 @@ export class TPS {
|
|
|
268
404
|
return null;
|
|
269
405
|
}
|
|
270
406
|
|
|
407
|
+
// --- DRIVER CONVENIENCE METHODS ---
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Parse a calendar-specific date string into TPS components.
|
|
411
|
+
* Requires the driver to implement the optional `parseDate` method.
|
|
412
|
+
*
|
|
413
|
+
* @param calendar - The calendar code (e.g., 'hij')
|
|
414
|
+
* @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
|
|
415
|
+
* @param format - Optional format string (driver-specific)
|
|
416
|
+
* @returns TPS components or null if parsing fails
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```ts
|
|
420
|
+
* const components = TPS.parseCalendarDate('hij', '1447-07-21');
|
|
421
|
+
* // { calendar: 'hij', year: 1447, month: 7, day: 21 }
|
|
422
|
+
*
|
|
423
|
+
* const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
|
|
424
|
+
* // "tps://31.95,35.91@T:hij.y1447.M07.d21"
|
|
425
|
+
* ```
|
|
426
|
+
*/
|
|
427
|
+
static parseCalendarDate(
|
|
428
|
+
calendar: CalendarCode,
|
|
429
|
+
dateString: string,
|
|
430
|
+
format?: string,
|
|
431
|
+
): Partial<TPSComponents> | null {
|
|
432
|
+
const driver = this.drivers.get(calendar);
|
|
433
|
+
if (!driver) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`Calendar driver '${calendar}' not found. Register a driver first.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (!driver.parseDate) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return driver.parseDate(dateString, format);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Convert a calendar-specific date string directly to a TPS URI.
|
|
448
|
+
* This is a convenience method that combines parseDate + toURI.
|
|
449
|
+
*
|
|
450
|
+
* @param calendar - The calendar code (e.g., 'hij')
|
|
451
|
+
* @param dateString - Date string in calendar-native format
|
|
452
|
+
* @param location - Optional location (lat/lon/alt or privacy flag)
|
|
453
|
+
* @returns Full TPS URI string
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* ```ts
|
|
457
|
+
* // With coordinates
|
|
458
|
+
* TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
|
|
459
|
+
* // "tps://31.95,35.91@T:hij.y1447.M07.d21"
|
|
460
|
+
*
|
|
461
|
+
* // With privacy flag
|
|
462
|
+
* TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
|
|
463
|
+
* // "tps://hidden@T:hij.y1447.M07.d21"
|
|
464
|
+
*
|
|
465
|
+
* // Without location
|
|
466
|
+
* TPS.fromCalendarDate('hij', '1447-07-21');
|
|
467
|
+
* // "tps://unknown@T:hij.y1447.M07.d21"
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
static fromCalendarDate(
|
|
471
|
+
calendar: CalendarCode,
|
|
472
|
+
dateString: string,
|
|
473
|
+
location?: {
|
|
474
|
+
latitude?: number;
|
|
475
|
+
longitude?: number;
|
|
476
|
+
altitude?: number;
|
|
477
|
+
isUnknownLocation?: boolean;
|
|
478
|
+
isHiddenLocation?: boolean;
|
|
479
|
+
isRedactedLocation?: boolean;
|
|
480
|
+
},
|
|
481
|
+
): string {
|
|
482
|
+
const components = this.parseCalendarDate(calendar, dateString);
|
|
483
|
+
if (!components) {
|
|
484
|
+
throw new Error(`Failed to parse date string: ${dateString}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Merge with location
|
|
488
|
+
const fullComponents: TPSComponents = {
|
|
489
|
+
calendar,
|
|
490
|
+
...components,
|
|
491
|
+
...location,
|
|
492
|
+
} as TPSComponents;
|
|
493
|
+
|
|
494
|
+
return this.toURI(fullComponents);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Format TPS components to a calendar-specific date string.
|
|
499
|
+
* Requires the driver to implement the optional `format` method.
|
|
500
|
+
*
|
|
501
|
+
* @param calendar - The calendar code
|
|
502
|
+
* @param components - TPS components to format
|
|
503
|
+
* @param format - Optional format string (driver-specific)
|
|
504
|
+
* @returns Formatted date string in calendar-native format
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* ```ts
|
|
508
|
+
* const tps = TPS.parse('tps://unknown@T:hij.y1447.M07.d21');
|
|
509
|
+
* const formatted = TPS.formatCalendarDate('hij', tps);
|
|
510
|
+
* // "1447-07-21"
|
|
511
|
+
* ```
|
|
512
|
+
*/
|
|
513
|
+
static formatCalendarDate(
|
|
514
|
+
calendar: CalendarCode,
|
|
515
|
+
components: Partial<TPSComponents>,
|
|
516
|
+
format?: string,
|
|
517
|
+
): string {
|
|
518
|
+
const driver = this.drivers.get(calendar);
|
|
519
|
+
if (!driver) {
|
|
520
|
+
throw new Error(`Calendar driver '${calendar}' not found.`);
|
|
521
|
+
}
|
|
522
|
+
if (!driver.format) {
|
|
523
|
+
throw new Error(`Driver '${calendar}' does not implement format().`);
|
|
524
|
+
}
|
|
525
|
+
return driver.format(components, format);
|
|
526
|
+
}
|
|
527
|
+
|
|
271
528
|
// --- INTERNAL HELPERS ---
|
|
272
529
|
|
|
273
530
|
private static _mapGroupsToComponents(
|
|
@@ -322,3 +579,775 @@ export class TPS {
|
|
|
322
579
|
return s.length < 2 ? '0' + s : s;
|
|
323
580
|
}
|
|
324
581
|
}
|
|
582
|
+
|
|
583
|
+
// --- TPS-UID v1 Types ---
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Decoded result from TPSUID7RB binary format.
|
|
587
|
+
*/
|
|
588
|
+
export type TPSUID7RBDecodeResult = {
|
|
589
|
+
/** Version identifier */
|
|
590
|
+
version: 'tpsuid7rb';
|
|
591
|
+
/** Epoch milliseconds (UTC) */
|
|
592
|
+
epochMs: number;
|
|
593
|
+
/** Whether the TPS payload was compressed */
|
|
594
|
+
compressed: boolean;
|
|
595
|
+
/** 32-bit nonce for collision prevention */
|
|
596
|
+
nonce: number;
|
|
597
|
+
/** The original TPS string (exact reconstruction) */
|
|
598
|
+
tps: string;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Encoding options for TPSUID7RB.
|
|
603
|
+
*/
|
|
604
|
+
export type TPSUID7RBEncodeOptions = {
|
|
605
|
+
/** Enable zlib compression of TPS payload */
|
|
606
|
+
compress?: boolean;
|
|
607
|
+
/** Override epoch milliseconds (default: parsed from TPS) */
|
|
608
|
+
epochMs?: number;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
|
|
613
|
+
*
|
|
614
|
+
* A time-first, reversible identifier that binds an event to a TPS coordinate.
|
|
615
|
+
* Unlike UUIDs, TPS-UID identifies events in spacetime and allows exact
|
|
616
|
+
* reconstruction of the original TPS string.
|
|
617
|
+
*
|
|
618
|
+
* Binary Schema (all integers big-endian):
|
|
619
|
+
* ```
|
|
620
|
+
* MAGIC 4 bytes "TPU7"
|
|
621
|
+
* VER 1 byte 0x01
|
|
622
|
+
* FLAGS 1 byte bit0 = compression flag
|
|
623
|
+
* TIME 6 bytes epoch_ms (48-bit unsigned)
|
|
624
|
+
* NONCE 4 bytes 32-bit random
|
|
625
|
+
* LEN varint length of TPS payload
|
|
626
|
+
* TPS bytes UTF-8 TPS string (raw or zlib-compressed)
|
|
627
|
+
* ```
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```ts
|
|
631
|
+
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.M01.d09';
|
|
632
|
+
*
|
|
633
|
+
* // Encode to binary
|
|
634
|
+
* const bytes = TPSUID7RB.encodeBinary(tps);
|
|
635
|
+
*
|
|
636
|
+
* // Encode to base64url string
|
|
637
|
+
* const id = TPSUID7RB.encodeBinaryB64(tps);
|
|
638
|
+
* // → "tpsuid7rb_AFRQV..."
|
|
639
|
+
*
|
|
640
|
+
* // Decode back to original TPS
|
|
641
|
+
* const decoded = TPSUID7RB.decodeBinaryB64(id);
|
|
642
|
+
* console.log(decoded.tps); // exact original TPS
|
|
643
|
+
* ```
|
|
644
|
+
*/
|
|
645
|
+
export class TPSUID7RB {
|
|
646
|
+
/** Magic bytes: "TPU7" */
|
|
647
|
+
private static readonly MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
|
|
648
|
+
/** Version 1 */
|
|
649
|
+
private static readonly VER = 0x01;
|
|
650
|
+
/** String prefix for base64url encoded form */
|
|
651
|
+
private static readonly PREFIX = 'tpsuid7rb_';
|
|
652
|
+
/** Regex for validating base64url encoded form */
|
|
653
|
+
public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|
|
654
|
+
|
|
655
|
+
// ---------------------------
|
|
656
|
+
// Public API
|
|
657
|
+
// ---------------------------
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Encode TPS string to binary bytes (Uint8Array).
|
|
661
|
+
* This is the canonical form for hashing, signing, and storage.
|
|
662
|
+
*
|
|
663
|
+
* @param tps - The TPS string to encode
|
|
664
|
+
* @param opts - Encoding options (compress, epochMs override)
|
|
665
|
+
* @returns Binary TPS-UID as Uint8Array
|
|
666
|
+
*/
|
|
667
|
+
static encodeBinary(tps: string, opts?: TPSUID7RBEncodeOptions): Uint8Array {
|
|
668
|
+
const compress = opts?.compress ?? false;
|
|
669
|
+
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
670
|
+
|
|
671
|
+
if (!Number.isInteger(epochMs) || epochMs < 0) {
|
|
672
|
+
throw new Error('epochMs must be a non-negative integer');
|
|
673
|
+
}
|
|
674
|
+
if (epochMs > 0xffffffffffff) {
|
|
675
|
+
throw new Error('epochMs exceeds 48-bit range');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const flags = compress ? 0x01 : 0x00;
|
|
679
|
+
|
|
680
|
+
// Generate 32-bit nonce
|
|
681
|
+
const nonceBuf = this.randomBytes(4);
|
|
682
|
+
const nonce =
|
|
683
|
+
((nonceBuf[0] << 24) >>> 0) +
|
|
684
|
+
((nonceBuf[1] << 16) >>> 0) +
|
|
685
|
+
((nonceBuf[2] << 8) >>> 0) +
|
|
686
|
+
nonceBuf[3];
|
|
687
|
+
|
|
688
|
+
// Encode TPS to UTF-8
|
|
689
|
+
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
690
|
+
|
|
691
|
+
// Optionally compress
|
|
692
|
+
const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
|
|
693
|
+
|
|
694
|
+
// Encode length as varint
|
|
695
|
+
const lenVar = this.uvarintEncode(payload.length);
|
|
696
|
+
|
|
697
|
+
// Construct binary structure
|
|
698
|
+
const out = new Uint8Array(
|
|
699
|
+
4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length,
|
|
700
|
+
);
|
|
701
|
+
let offset = 0;
|
|
702
|
+
|
|
703
|
+
// MAGIC
|
|
704
|
+
out.set(this.MAGIC, offset);
|
|
705
|
+
offset += 4;
|
|
706
|
+
|
|
707
|
+
// VER
|
|
708
|
+
out[offset++] = this.VER;
|
|
709
|
+
|
|
710
|
+
// FLAGS
|
|
711
|
+
out[offset++] = flags;
|
|
712
|
+
|
|
713
|
+
// TIME (48-bit big-endian)
|
|
714
|
+
const timeBytes = this.writeU48(epochMs);
|
|
715
|
+
out.set(timeBytes, offset);
|
|
716
|
+
offset += 6;
|
|
717
|
+
|
|
718
|
+
// NONCE (32-bit big-endian)
|
|
719
|
+
out.set(nonceBuf, offset);
|
|
720
|
+
offset += 4;
|
|
721
|
+
|
|
722
|
+
// LEN (varint)
|
|
723
|
+
out.set(lenVar, offset);
|
|
724
|
+
offset += lenVar.length;
|
|
725
|
+
|
|
726
|
+
// TPS payload
|
|
727
|
+
out.set(payload, offset);
|
|
728
|
+
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Decode binary bytes back to original TPS string.
|
|
734
|
+
*
|
|
735
|
+
* @param bytes - Binary TPS-UID
|
|
736
|
+
* @returns Decoded result with original TPS string
|
|
737
|
+
*/
|
|
738
|
+
static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
|
|
739
|
+
// Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
|
|
740
|
+
if (bytes.length < 17) {
|
|
741
|
+
throw new Error('TPSUID7RB: too short');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// MAGIC
|
|
745
|
+
if (
|
|
746
|
+
bytes[0] !== 0x54 ||
|
|
747
|
+
bytes[1] !== 0x50 ||
|
|
748
|
+
bytes[2] !== 0x55 ||
|
|
749
|
+
bytes[3] !== 0x37
|
|
750
|
+
) {
|
|
751
|
+
throw new Error('TPSUID7RB: bad magic');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// VERSION
|
|
755
|
+
const ver = bytes[4];
|
|
756
|
+
if (ver !== this.VER) {
|
|
757
|
+
throw new Error(`TPSUID7RB: unsupported version ${ver}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// FLAGS
|
|
761
|
+
const flags = bytes[5];
|
|
762
|
+
const compressed = (flags & 0x01) === 0x01;
|
|
763
|
+
|
|
764
|
+
// TIME (48-bit big-endian)
|
|
765
|
+
const epochMs = this.readU48(bytes, 6);
|
|
766
|
+
|
|
767
|
+
// NONCE (32-bit big-endian)
|
|
768
|
+
const nonce =
|
|
769
|
+
((bytes[12] << 24) >>> 0) +
|
|
770
|
+
((bytes[13] << 16) >>> 0) +
|
|
771
|
+
((bytes[14] << 8) >>> 0) +
|
|
772
|
+
bytes[15];
|
|
773
|
+
|
|
774
|
+
// LEN (varint at offset 16)
|
|
775
|
+
let offset = 16;
|
|
776
|
+
const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
|
|
777
|
+
offset += bytesRead;
|
|
778
|
+
|
|
779
|
+
if (offset + tpsLen > bytes.length) {
|
|
780
|
+
throw new Error('TPSUID7RB: length overflow');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// TPS payload
|
|
784
|
+
const payload = bytes.slice(offset, offset + tpsLen);
|
|
785
|
+
const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
|
|
786
|
+
const tps = new TextDecoder().decode(tpsUtf8);
|
|
787
|
+
|
|
788
|
+
return { version: 'tpsuid7rb', epochMs, compressed, nonce, tps };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Encode TPS to base64url string with prefix.
|
|
793
|
+
* This is the transport/storage form.
|
|
794
|
+
*
|
|
795
|
+
* @param tps - The TPS string to encode
|
|
796
|
+
* @param opts - Encoding options
|
|
797
|
+
* @returns Base64url encoded TPS-UID with prefix
|
|
798
|
+
*/
|
|
799
|
+
static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
|
|
800
|
+
const bytes = this.encodeBinary(tps, opts);
|
|
801
|
+
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Decode base64url string back to original TPS string.
|
|
806
|
+
*
|
|
807
|
+
* @param id - Base64url encoded TPS-UID with prefix
|
|
808
|
+
* @returns Decoded result with original TPS string
|
|
809
|
+
*/
|
|
810
|
+
static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
|
|
811
|
+
const s = id.trim();
|
|
812
|
+
if (!s.startsWith(this.PREFIX)) {
|
|
813
|
+
throw new Error('TPSUID7RB: missing prefix');
|
|
814
|
+
}
|
|
815
|
+
const b64 = s.slice(this.PREFIX.length);
|
|
816
|
+
const bytes = this.base64UrlDecode(b64);
|
|
817
|
+
return this.decodeBinary(bytes);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Validate base64url encoded TPS-UID format.
|
|
822
|
+
* Note: This validates shape only; binary decode is authoritative.
|
|
823
|
+
*
|
|
824
|
+
* @param id - String to validate
|
|
825
|
+
* @returns true if format is valid
|
|
826
|
+
*/
|
|
827
|
+
static validateBinaryB64(id: string): boolean {
|
|
828
|
+
return this.REGEX.test(id.trim());
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Generate a TPS-UID from the current time and optional location.
|
|
833
|
+
*
|
|
834
|
+
* @param opts - Generation options
|
|
835
|
+
* @returns Base64url encoded TPS-UID
|
|
836
|
+
*/
|
|
837
|
+
static generate(opts?: {
|
|
838
|
+
latitude?: number;
|
|
839
|
+
longitude?: number;
|
|
840
|
+
altitude?: number;
|
|
841
|
+
compress?: boolean;
|
|
842
|
+
}): string {
|
|
843
|
+
const now = new Date();
|
|
844
|
+
const tps = this.generateTPSString(now, opts);
|
|
845
|
+
return this.encodeBinaryB64(tps, {
|
|
846
|
+
compress: opts?.compress,
|
|
847
|
+
epochMs: now.getTime(),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ---------------------------
|
|
852
|
+
// TPS String Helpers
|
|
853
|
+
// ---------------------------
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Generate a TPS string from a Date and optional location.
|
|
857
|
+
*/
|
|
858
|
+
private static generateTPSString(
|
|
859
|
+
date: Date,
|
|
860
|
+
opts?: { latitude?: number; longitude?: number; altitude?: number },
|
|
861
|
+
): string {
|
|
862
|
+
const fullYear = date.getUTCFullYear();
|
|
863
|
+
const m = Math.floor(fullYear / 1000) + 1;
|
|
864
|
+
const c = Math.floor((fullYear % 1000) / 100) + 1;
|
|
865
|
+
const y = fullYear % 100;
|
|
866
|
+
const M = date.getUTCMonth() + 1;
|
|
867
|
+
const d = date.getUTCDate();
|
|
868
|
+
const h = date.getUTCHours();
|
|
869
|
+
const n = date.getUTCMinutes();
|
|
870
|
+
const s = date.getUTCSeconds();
|
|
871
|
+
|
|
872
|
+
const pad = (num: number) => num.toString().padStart(2, '0');
|
|
873
|
+
|
|
874
|
+
const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(h)}.n${pad(n)}.s${pad(s)}`;
|
|
875
|
+
|
|
876
|
+
let spacePart = 'unknown';
|
|
877
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
878
|
+
spacePart = `${opts.latitude},${opts.longitude}`;
|
|
879
|
+
if (opts.altitude !== undefined) {
|
|
880
|
+
spacePart += `,${opts.altitude}m`;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return `tps://${spacePart}@${timePart}`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Parse epoch milliseconds from a TPS string.
|
|
889
|
+
* Supports both URI format (tps://...) and time-only format (T:greg...)
|
|
890
|
+
*/
|
|
891
|
+
static epochMsFromTPSString(tps: string): number {
|
|
892
|
+
let time: string;
|
|
893
|
+
|
|
894
|
+
if (tps.includes('@')) {
|
|
895
|
+
// URI format: tps://...@T:greg...
|
|
896
|
+
const at = tps.indexOf('@');
|
|
897
|
+
time = tps.slice(at + 1).trim();
|
|
898
|
+
} else if (tps.startsWith('T:')) {
|
|
899
|
+
// Time-only format
|
|
900
|
+
time = tps;
|
|
901
|
+
} else {
|
|
902
|
+
throw new Error('TPS: unrecognized format');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (!time.startsWith('T:greg.')) {
|
|
906
|
+
throw new Error('TPS: only T:greg.* parsing is supported');
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Extract m (millennium), c (century), y (year)
|
|
910
|
+
const mMatch = time.match(/\.m(-?\d+)/);
|
|
911
|
+
const cMatch = time.match(/\.c(\d+)/);
|
|
912
|
+
const yMatch = time.match(/\.y(\d{1,4})/);
|
|
913
|
+
const MMatch = time.match(/\.M(\d{1,2})/);
|
|
914
|
+
const dMatch = time.match(/\.d(\d{1,2})/);
|
|
915
|
+
const hMatch = time.match(/\.h(\d{1,2})/);
|
|
916
|
+
const nMatch = time.match(/\.n(\d{1,2})/);
|
|
917
|
+
const sMatch = time.match(/\.s(\d{1,2})/);
|
|
918
|
+
|
|
919
|
+
// Calculate full year from millennium, century, year
|
|
920
|
+
let fullYear: number;
|
|
921
|
+
if (mMatch && cMatch && yMatch) {
|
|
922
|
+
const millennium = parseInt(mMatch[1], 10);
|
|
923
|
+
const century = parseInt(cMatch[1], 10);
|
|
924
|
+
const year = parseInt(yMatch[1], 10);
|
|
925
|
+
fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
926
|
+
} else if (yMatch) {
|
|
927
|
+
// Fallback: interpret y as 2-digit year
|
|
928
|
+
let year = parseInt(yMatch[1], 10);
|
|
929
|
+
if (year < 100) {
|
|
930
|
+
year = year <= 69 ? 2000 + year : 1900 + year;
|
|
931
|
+
}
|
|
932
|
+
fullYear = year;
|
|
933
|
+
} else {
|
|
934
|
+
throw new Error('TPS: missing year component');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const month = MMatch ? parseInt(MMatch[1], 10) : 1;
|
|
938
|
+
const day = dMatch ? parseInt(dMatch[1], 10) : 1;
|
|
939
|
+
const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
|
|
940
|
+
const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
|
|
941
|
+
const second = sMatch ? parseInt(sMatch[1], 10) : 0;
|
|
942
|
+
|
|
943
|
+
const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
|
|
944
|
+
if (!Number.isFinite(epoch)) {
|
|
945
|
+
throw new Error('TPS: failed to compute epochMs');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return epoch;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------------------------
|
|
952
|
+
// Binary Helpers
|
|
953
|
+
// ---------------------------
|
|
954
|
+
|
|
955
|
+
/** Write 48-bit unsigned integer (big-endian) */
|
|
956
|
+
private static writeU48(epochMs: number): Uint8Array {
|
|
957
|
+
const b = new Uint8Array(6);
|
|
958
|
+
// Use BigInt for proper 48-bit handling
|
|
959
|
+
const v = BigInt(epochMs);
|
|
960
|
+
b[0] = Number((v >> 40n) & 0xffn);
|
|
961
|
+
b[1] = Number((v >> 32n) & 0xffn);
|
|
962
|
+
b[2] = Number((v >> 24n) & 0xffn);
|
|
963
|
+
b[3] = Number((v >> 16n) & 0xffn);
|
|
964
|
+
b[4] = Number((v >> 8n) & 0xffn);
|
|
965
|
+
b[5] = Number(v & 0xffn);
|
|
966
|
+
return b;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Read 48-bit unsigned integer (big-endian) */
|
|
970
|
+
private static readU48(bytes: Uint8Array, offset: number): number {
|
|
971
|
+
const v =
|
|
972
|
+
(BigInt(bytes[offset]) << 40n) +
|
|
973
|
+
(BigInt(bytes[offset + 1]) << 32n) +
|
|
974
|
+
(BigInt(bytes[offset + 2]) << 24n) +
|
|
975
|
+
(BigInt(bytes[offset + 3]) << 16n) +
|
|
976
|
+
(BigInt(bytes[offset + 4]) << 8n) +
|
|
977
|
+
BigInt(bytes[offset + 5]);
|
|
978
|
+
|
|
979
|
+
const n = Number(v);
|
|
980
|
+
if (!Number.isSafeInteger(n)) {
|
|
981
|
+
throw new Error('TPSUID7RB: u48 not safe integer');
|
|
982
|
+
}
|
|
983
|
+
return n;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Encode unsigned integer as LEB128 varint */
|
|
987
|
+
private static uvarintEncode(n: number): Uint8Array {
|
|
988
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
989
|
+
throw new Error('uvarint must be non-negative int');
|
|
990
|
+
}
|
|
991
|
+
const out: number[] = [];
|
|
992
|
+
let x = n >>> 0;
|
|
993
|
+
while (x >= 0x80) {
|
|
994
|
+
out.push((x & 0x7f) | 0x80);
|
|
995
|
+
x >>>= 7;
|
|
996
|
+
}
|
|
997
|
+
out.push(x);
|
|
998
|
+
return new Uint8Array(out);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Decode LEB128 varint */
|
|
1002
|
+
private static uvarintDecode(
|
|
1003
|
+
bytes: Uint8Array,
|
|
1004
|
+
offset: number,
|
|
1005
|
+
): { value: number; bytesRead: number } {
|
|
1006
|
+
let x = 0;
|
|
1007
|
+
let s = 0;
|
|
1008
|
+
let i = 0;
|
|
1009
|
+
while (true) {
|
|
1010
|
+
if (offset + i >= bytes.length) {
|
|
1011
|
+
throw new Error('uvarint overflow');
|
|
1012
|
+
}
|
|
1013
|
+
const b = bytes[offset + i];
|
|
1014
|
+
if (b < 0x80) {
|
|
1015
|
+
if (i > 9 || (i === 9 && b > 1)) {
|
|
1016
|
+
throw new Error('uvarint too large');
|
|
1017
|
+
}
|
|
1018
|
+
x |= b << s;
|
|
1019
|
+
return { value: x >>> 0, bytesRead: i + 1 };
|
|
1020
|
+
}
|
|
1021
|
+
x |= (b & 0x7f) << s;
|
|
1022
|
+
s += 7;
|
|
1023
|
+
i++;
|
|
1024
|
+
if (i > 10) {
|
|
1025
|
+
throw new Error('uvarint too long');
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ---------------------------
|
|
1031
|
+
// Base64url Helpers
|
|
1032
|
+
// ---------------------------
|
|
1033
|
+
|
|
1034
|
+
/** Encode bytes to base64url (no padding) */
|
|
1035
|
+
private static base64UrlEncode(bytes: Uint8Array): string {
|
|
1036
|
+
// Node.js environment
|
|
1037
|
+
if (typeof Buffer !== 'undefined') {
|
|
1038
|
+
return Buffer.from(bytes)
|
|
1039
|
+
.toString('base64')
|
|
1040
|
+
.replace(/\+/g, '-')
|
|
1041
|
+
.replace(/\//g, '_')
|
|
1042
|
+
.replace(/=+$/g, '');
|
|
1043
|
+
}
|
|
1044
|
+
// Browser environment
|
|
1045
|
+
let binary = '';
|
|
1046
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1047
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1048
|
+
}
|
|
1049
|
+
return btoa(binary)
|
|
1050
|
+
.replace(/\+/g, '-')
|
|
1051
|
+
.replace(/\//g, '_')
|
|
1052
|
+
.replace(/=+$/g, '');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Decode base64url to bytes */
|
|
1056
|
+
private static base64UrlDecode(b64url: string): Uint8Array {
|
|
1057
|
+
// Add padding
|
|
1058
|
+
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
1059
|
+
const b64 = (b64url + '='.repeat(padLen))
|
|
1060
|
+
.replace(/-/g, '+')
|
|
1061
|
+
.replace(/_/g, '/');
|
|
1062
|
+
|
|
1063
|
+
// Node.js environment
|
|
1064
|
+
if (typeof Buffer !== 'undefined') {
|
|
1065
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
1066
|
+
}
|
|
1067
|
+
// Browser environment
|
|
1068
|
+
const binary = atob(b64);
|
|
1069
|
+
const bytes = new Uint8Array(binary.length);
|
|
1070
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1071
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1072
|
+
}
|
|
1073
|
+
return bytes;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// ---------------------------
|
|
1077
|
+
// Compression Helpers
|
|
1078
|
+
// ---------------------------
|
|
1079
|
+
|
|
1080
|
+
/** Compress using zlib deflate raw */
|
|
1081
|
+
private static deflateRaw(data: Uint8Array): Uint8Array {
|
|
1082
|
+
// Node.js environment
|
|
1083
|
+
if (typeof require !== 'undefined') {
|
|
1084
|
+
try {
|
|
1085
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1086
|
+
const zlib = require('zlib');
|
|
1087
|
+
return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
|
|
1088
|
+
} catch {
|
|
1089
|
+
throw new Error('TPSUID7RB: compression not available');
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
// Browser: would need pako or similar library
|
|
1093
|
+
throw new Error('TPSUID7RB: compression not available in browser');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Decompress using zlib inflate raw */
|
|
1097
|
+
private static inflateRaw(data: Uint8Array): Uint8Array {
|
|
1098
|
+
// Node.js environment
|
|
1099
|
+
if (typeof require !== 'undefined') {
|
|
1100
|
+
try {
|
|
1101
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1102
|
+
const zlib = require('zlib');
|
|
1103
|
+
return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
|
|
1104
|
+
} catch {
|
|
1105
|
+
throw new Error('TPSUID7RB: decompression failed');
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// Browser: would need pako or similar library
|
|
1109
|
+
throw new Error('TPSUID7RB: decompression not available in browser');
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ---------------------------
|
|
1113
|
+
// Cryptographic Sealing (Ed25519)
|
|
1114
|
+
// ---------------------------
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Seal (sign) a TPS string to create a cryptographically verifiable TPS-UID.
|
|
1118
|
+
* This appends an Ed25519 signature to the binary form.
|
|
1119
|
+
*
|
|
1120
|
+
* @param tps - The TPS string to seal
|
|
1121
|
+
* @param privateKey - Ed25519 private key (hex or buffer)
|
|
1122
|
+
* @param opts - Encoding options
|
|
1123
|
+
* @returns Sealed binary TPS-UID
|
|
1124
|
+
*/
|
|
1125
|
+
static seal(
|
|
1126
|
+
tps: string,
|
|
1127
|
+
privateKey: string | Buffer | Uint8Array,
|
|
1128
|
+
opts?: TPSUID7RBEncodeOptions,
|
|
1129
|
+
): Uint8Array {
|
|
1130
|
+
// 1. Create standard binary (unsealed first)
|
|
1131
|
+
// We force the SEAL flag (bit 1) to be 0 initially for the "content to sign"
|
|
1132
|
+
// But wait, we want the signature to cover the header too.
|
|
1133
|
+
// Strategy: Construct the full binary with SEAL flag OFF, sign it, then set SEAL flag ON and append sig.
|
|
1134
|
+
// Actually, the standard way is:
|
|
1135
|
+
// Content = MAGIC + VER + FLAGS(with seal bit set) + TIME + NONCE + LEN + PAYLOAD
|
|
1136
|
+
// Signature = Sign(Content)
|
|
1137
|
+
// Final = Content + SEAL_TYPE + SIGNATURE
|
|
1138
|
+
|
|
1139
|
+
const compress = opts?.compress ?? false;
|
|
1140
|
+
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
1141
|
+
|
|
1142
|
+
// Validate epoch
|
|
1143
|
+
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
1144
|
+
throw new Error('epochMs must be a valid 48-bit non-negative integer');
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Flags: Bit 0 = compress, Bit 1 = sealed
|
|
1148
|
+
const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
|
|
1149
|
+
|
|
1150
|
+
// Generate Nonce
|
|
1151
|
+
const nonceBuf = this.randomBytes(4);
|
|
1152
|
+
|
|
1153
|
+
// Encode Payload
|
|
1154
|
+
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
1155
|
+
const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
|
|
1156
|
+
const lenVar = this.uvarintEncode(payload.length);
|
|
1157
|
+
|
|
1158
|
+
// Construct Content (Header + Payload)
|
|
1159
|
+
const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
|
|
1160
|
+
const content = new Uint8Array(contentLen);
|
|
1161
|
+
let offset = 0;
|
|
1162
|
+
|
|
1163
|
+
content.set(this.MAGIC, offset); offset += 4;
|
|
1164
|
+
content[offset++] = this.VER;
|
|
1165
|
+
content[offset++] = flags;
|
|
1166
|
+
content.set(this.writeU48(epochMs), offset); offset += 6;
|
|
1167
|
+
content.set(nonceBuf, offset); offset += 4;
|
|
1168
|
+
content.set(lenVar, offset); offset += lenVar.length;
|
|
1169
|
+
content.set(payload, offset);
|
|
1170
|
+
|
|
1171
|
+
// Sign the content
|
|
1172
|
+
const signature = this.signEd25519(content, privateKey);
|
|
1173
|
+
const sealType = 0x01; // Ed25519
|
|
1174
|
+
|
|
1175
|
+
// Final Output: Content + SealType (1) + Signature (64)
|
|
1176
|
+
const final = new Uint8Array(contentLen + 1 + signature.length);
|
|
1177
|
+
final.set(content, 0);
|
|
1178
|
+
final.set([sealType], contentLen);
|
|
1179
|
+
final.set(signature, contentLen + 1);
|
|
1180
|
+
|
|
1181
|
+
return final;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Verify a sealed TPS-UID and decode it.
|
|
1186
|
+
* Throws if signature is invalid or not sealed.
|
|
1187
|
+
*
|
|
1188
|
+
* @param sealedBytes - The binary sealed TPS-UID
|
|
1189
|
+
* @param publicKey - Ed25519 public key (hex or buffer) to verify against
|
|
1190
|
+
* @returns Decoded result
|
|
1191
|
+
*/
|
|
1192
|
+
static verifyAndDecode(
|
|
1193
|
+
sealedBytes: Uint8Array,
|
|
1194
|
+
publicKey: string | Buffer | Uint8Array,
|
|
1195
|
+
): TPSUID7RBDecodeResult {
|
|
1196
|
+
if (sealedBytes.length < 18) throw new Error('TPSUID7RB: too short');
|
|
1197
|
+
|
|
1198
|
+
// Check Magic
|
|
1199
|
+
if (
|
|
1200
|
+
sealedBytes[0] !== 0x54 ||
|
|
1201
|
+
sealedBytes[1] !== 0x50 ||
|
|
1202
|
+
sealedBytes[2] !== 0x55 ||
|
|
1203
|
+
sealedBytes[3] !== 0x37
|
|
1204
|
+
) {
|
|
1205
|
+
throw new Error('TPSUID7RB: bad magic');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Check Flags for Sealed Bit (bit 1)
|
|
1209
|
+
const flags = sealedBytes[5];
|
|
1210
|
+
if ((flags & 0x02) === 0) {
|
|
1211
|
+
throw new Error('TPSUID7RB: not a sealed UID');
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// 1. Parse the structure to find where content ends
|
|
1215
|
+
// We need to parse LEN and Payload to find the split point
|
|
1216
|
+
let offset = 16; // Start of LEN
|
|
1217
|
+
// Decode LEN
|
|
1218
|
+
const { value: tpsLen, bytesRead } = this.uvarintDecode(sealedBytes, offset);
|
|
1219
|
+
offset += bytesRead;
|
|
1220
|
+
const payloadEnd = offset + tpsLen;
|
|
1221
|
+
|
|
1222
|
+
if (payloadEnd > sealedBytes.length) {
|
|
1223
|
+
throw new Error('TPSUID7RB: length overflow (truncated)');
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// The Content to verify matches exactly [0 ... payloadEnd]
|
|
1227
|
+
const content = sealedBytes.slice(0, payloadEnd);
|
|
1228
|
+
|
|
1229
|
+
// After content: SealType (1 byte) + Signature
|
|
1230
|
+
if (sealedBytes.length <= payloadEnd + 1) {
|
|
1231
|
+
throw new Error('TPSUID7RB: missing signature data');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const sealType = sealedBytes[payloadEnd];
|
|
1235
|
+
if (sealType !== 0x01) {
|
|
1236
|
+
throw new Error(`TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const signature = sealedBytes.slice(payloadEnd + 1);
|
|
1240
|
+
if (signature.length !== 64) {
|
|
1241
|
+
throw new Error(`TPSUID7RB: invalid Ed25519 signature length ${signature.length}`);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Verify
|
|
1245
|
+
const isValid = this.verifyEd25519(content, signature, publicKey);
|
|
1246
|
+
if (!isValid) {
|
|
1247
|
+
throw new Error('TPSUID7RB: signature verification failed');
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
|
|
1251
|
+
// Actually standard logic doesn't expect trailing bytes unless we tell it to.
|
|
1252
|
+
// But since we verified, we can just slice the content and decode that as a strict binary
|
|
1253
|
+
// EXCEPT standard decodeBinary checks strict length.
|
|
1254
|
+
// So we manually decode the components here to be safe and efficient.
|
|
1255
|
+
|
|
1256
|
+
return this.decodeBinary(content); // Reuse strict decoder on the content part
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// --- Crypto Implementation (Ed25519) ---
|
|
1260
|
+
|
|
1261
|
+
private static signEd25519(
|
|
1262
|
+
data: Uint8Array,
|
|
1263
|
+
privateKey: string | Buffer | Uint8Array,
|
|
1264
|
+
): Uint8Array {
|
|
1265
|
+
if (typeof require !== 'undefined') {
|
|
1266
|
+
try {
|
|
1267
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1268
|
+
const crypto = require('crypto');
|
|
1269
|
+
// Node's crypto.sign uses PEM or KeyObject, but for raw Ed25519 keys we might need 'crypto.sign(null, data, key)'
|
|
1270
|
+
// or ensure key is properly formatted.
|
|
1271
|
+
// For simplicity in Node 20+, crypto.sign(null, data, privateKey) works if key is KeyObject.
|
|
1272
|
+
// If raw bytes: establish KeyObject.
|
|
1273
|
+
|
|
1274
|
+
let keyObj;
|
|
1275
|
+
if (Buffer.isBuffer(privateKey) || privateKey instanceof Uint8Array) {
|
|
1276
|
+
// Assuming raw 64-byte private key (or 32-byte seed properly expanded by crypto)
|
|
1277
|
+
// Node < 16 is tricky with raw keys.
|
|
1278
|
+
// Let's assume standard Ed25519 standard implementation pattern logic:
|
|
1279
|
+
keyObj = crypto.createPrivateKey({
|
|
1280
|
+
key: Buffer.from(privateKey),
|
|
1281
|
+
format: 'der', // or 'pem' - strict.
|
|
1282
|
+
type: 'pkcs8'
|
|
1283
|
+
});
|
|
1284
|
+
// Actually, simpler: construct key object from raw bytes if possible?
|
|
1285
|
+
// Node's crypto is strict. Let's try the simplest:
|
|
1286
|
+
// If hex string provided, convert to buffer.
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Simpler fallback: If user passed a PEM string, great.
|
|
1290
|
+
// If they passed raw bytes, we might need 'ed25519' key type.
|
|
1291
|
+
// For this implementation, let's target Node's high-level sign/verify
|
|
1292
|
+
// and assume the user provides a VALID key object or compatible format (PEM/DER).
|
|
1293
|
+
// Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
|
|
1294
|
+
|
|
1295
|
+
const key = typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
|
|
1296
|
+
? crypto.createPrivateKey({ key: Buffer.from(privateKey, 'hex'), format: 'pem', type: 'pkcs8' }) // Fallback guess
|
|
1297
|
+
: privateKey;
|
|
1298
|
+
|
|
1299
|
+
// Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
|
|
1300
|
+
// Direct raw signing is via crypto.sign(null, data, key).
|
|
1301
|
+
return new Uint8Array(crypto.sign(null, data, key));
|
|
1302
|
+
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
// If standard crypto fails (e.g. key format issue), throw
|
|
1305
|
+
throw new Error('TPSUID7RB: signing failed (check key format)');
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
throw new Error('TPSUID7RB: signing not available in browser');
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private static verifyEd25519(
|
|
1312
|
+
data: Uint8Array,
|
|
1313
|
+
signature: Uint8Array,
|
|
1314
|
+
publicKey: string | Buffer | Uint8Array,
|
|
1315
|
+
): boolean {
|
|
1316
|
+
if (typeof require !== 'undefined') {
|
|
1317
|
+
try {
|
|
1318
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1319
|
+
const crypto = require('crypto');
|
|
1320
|
+
return crypto.verify(null, data, publicKey, signature);
|
|
1321
|
+
} catch {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
throw new Error('TPSUID7RB: verification not available in browser');
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// ---------------------------
|
|
1329
|
+
// Random Bytes
|
|
1330
|
+
// ---------------------------
|
|
1331
|
+
|
|
1332
|
+
/** Generate cryptographically secure random bytes */
|
|
1333
|
+
private static randomBytes(length: number): Uint8Array {
|
|
1334
|
+
// Node.js environment
|
|
1335
|
+
if (typeof require !== 'undefined') {
|
|
1336
|
+
try {
|
|
1337
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1338
|
+
const crypto = require('crypto');
|
|
1339
|
+
return new Uint8Array(crypto.randomBytes(length));
|
|
1340
|
+
} catch {
|
|
1341
|
+
// Fallback to crypto.getRandomValues
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
// Browser or fallback
|
|
1345
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
1346
|
+
const bytes = new Uint8Array(length);
|
|
1347
|
+
crypto.getRandomValues(bytes);
|
|
1348
|
+
return bytes;
|
|
1349
|
+
}
|
|
1350
|
+
throw new Error('TPSUID7RB: no crypto available');
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|