@tnid/encryption 0.0.4
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/LICENSE +21 -0
- package/README.md +148 -0
- package/esm/_dnt.shims.d.ts +2 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +57 -0
- package/esm/aes.d.ts +25 -0
- package/esm/aes.d.ts.map +1 -0
- package/esm/aes.js +62 -0
- package/esm/bits.d.ts +45 -0
- package/esm/bits.d.ts.map +1 -0
- package/esm/bits.js +109 -0
- package/esm/encryption.d.ts +56 -0
- package/esm/encryption.d.ts.map +1 -0
- package/esm/encryption.js +194 -0
- package/esm/ff1.d.ts +45 -0
- package/esm/ff1.d.ts.map +1 -0
- package/esm/ff1.js +240 -0
- package/esm/index.d.ts +27 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +26 -0
- package/esm/package.json +3 -0
- package/package.json +40 -0
- package/script/_dnt.shims.d.ts +2 -0
- package/script/_dnt.shims.d.ts.map +1 -0
- package/script/_dnt.shims.js +60 -0
- package/script/aes.d.ts +25 -0
- package/script/aes.d.ts.map +1 -0
- package/script/aes.js +99 -0
- package/script/bits.d.ts +45 -0
- package/script/bits.d.ts.map +1 -0
- package/script/bits.js +118 -0
- package/script/encryption.d.ts +56 -0
- package/script/encryption.d.ts.map +1 -0
- package/script/encryption.js +202 -0
- package/script/ff1.d.ts +45 -0
- package/script/ff1.d.ts.map +1 -0
- package/script/ff1.js +244 -0
- package/script/index.d.ts +27 -0
- package/script/index.d.ts.map +1 -0
- package/script/index.js +34 -0
- package/script/package.json +3 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TNID encryption using FF1 Format-Preserving Encryption.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to convert V0 (time-ordered) TNIDs to V1 (random-looking)
|
|
5
|
+
* TNIDs and vice versa, hiding timestamp information while remaining reversible.
|
|
6
|
+
*/
|
|
7
|
+
import { DynamicTnid } from "@tnid/core";
|
|
8
|
+
import { parseUuidStringToValue, valueToTnidString, extractVariantFromValue, } from "@tnid/core/uuid";
|
|
9
|
+
import { FF1 } from "./ff1.js";
|
|
10
|
+
import { COMPLETE_SECRET_DATA_MASK, expandSecretDataBits, extractSecretDataBits, fromHexDigits, setVariant, toHexDigits, } from "./bits.js";
|
|
11
|
+
/**
|
|
12
|
+
* Error when creating an EncryptionKey.
|
|
13
|
+
*/
|
|
14
|
+
export class EncryptionKeyError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "EncryptionKeyError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Error when encrypting or decrypting a TNID.
|
|
22
|
+
*/
|
|
23
|
+
export class EncryptionError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "EncryptionError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A 128-bit (16 byte) encryption key for TNID encryption.
|
|
31
|
+
*/
|
|
32
|
+
export class EncryptionKey {
|
|
33
|
+
constructor(bytes) {
|
|
34
|
+
Object.defineProperty(this, "bytes", {
|
|
35
|
+
enumerable: true,
|
|
36
|
+
configurable: true,
|
|
37
|
+
writable: true,
|
|
38
|
+
value: void 0
|
|
39
|
+
});
|
|
40
|
+
this.bytes = bytes;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Creates a new encryption key from raw bytes.
|
|
44
|
+
*/
|
|
45
|
+
static fromBytes(bytes) {
|
|
46
|
+
if (bytes.length !== 16) {
|
|
47
|
+
throw new EncryptionKeyError(`Encryption key must be 16 bytes, got ${bytes.length}`);
|
|
48
|
+
}
|
|
49
|
+
return new EncryptionKey(new Uint8Array(bytes));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates an encryption key from a 32-character hex string.
|
|
53
|
+
*/
|
|
54
|
+
static fromHex(hex) {
|
|
55
|
+
if (hex.length !== 32) {
|
|
56
|
+
throw new EncryptionKeyError(`Encryption key hex string must be 32 characters, got ${hex.length}`);
|
|
57
|
+
}
|
|
58
|
+
const bytes = new Uint8Array(16);
|
|
59
|
+
for (let i = 0; i < 16; i++) {
|
|
60
|
+
const hexByte = hex.slice(i * 2, i * 2 + 2);
|
|
61
|
+
const value = parseInt(hexByte, 16);
|
|
62
|
+
if (isNaN(value)) {
|
|
63
|
+
throw new EncryptionKeyError(`Invalid hex character at position ${i * 2}`);
|
|
64
|
+
}
|
|
65
|
+
bytes[i] = value;
|
|
66
|
+
}
|
|
67
|
+
return new EncryptionKey(bytes);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Returns the key as a byte array.
|
|
71
|
+
*/
|
|
72
|
+
asBytes() {
|
|
73
|
+
return new Uint8Array(this.bytes);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Convert TNID string to 128-bit value.
|
|
78
|
+
*/
|
|
79
|
+
function tnidToValue(tnid) {
|
|
80
|
+
// Use @tnid/core to convert to UUID, then parse UUID to value
|
|
81
|
+
const parsed = DynamicTnid.parse(tnid);
|
|
82
|
+
const uuid = DynamicTnid.toUuidString(parsed);
|
|
83
|
+
return parseUuidStringToValue(uuid);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Convert 128-bit value back to TNID string.
|
|
87
|
+
*/
|
|
88
|
+
function valueToTnid(value) {
|
|
89
|
+
return valueToTnidString(value);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Encrypts the 100-bit Payload using FF1.
|
|
93
|
+
*/
|
|
94
|
+
async function encryptPayload(payload, key) {
|
|
95
|
+
// Mask to 100 bits
|
|
96
|
+
const mask = (1n << 100n) - 1n;
|
|
97
|
+
const data = payload & mask;
|
|
98
|
+
// Convert to hex digits
|
|
99
|
+
const hexDigits = toHexDigits(data);
|
|
100
|
+
// Create FF1 cipher with radix 16
|
|
101
|
+
const ff1 = new FF1(key.asBytes(), 16);
|
|
102
|
+
// Encrypt with empty tweak
|
|
103
|
+
const encrypted = await ff1.encrypt(new Uint8Array(0), hexDigits);
|
|
104
|
+
// Convert back to bigint
|
|
105
|
+
return fromHexDigits(encrypted);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Decrypts the 100-bit Payload using FF1.
|
|
109
|
+
*/
|
|
110
|
+
async function decryptPayload(payload, key) {
|
|
111
|
+
// Mask to 100 bits
|
|
112
|
+
const mask = (1n << 100n) - 1n;
|
|
113
|
+
const data = payload & mask;
|
|
114
|
+
// Convert to hex digits
|
|
115
|
+
const hexDigits = toHexDigits(data);
|
|
116
|
+
// Create FF1 cipher with radix 16
|
|
117
|
+
const ff1 = new FF1(key.asBytes(), 16);
|
|
118
|
+
// Decrypt with empty tweak
|
|
119
|
+
const decrypted = await ff1.decrypt(new Uint8Array(0), hexDigits);
|
|
120
|
+
// Convert back to bigint
|
|
121
|
+
return fromHexDigits(decrypted);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Encrypts a V0 TNID to V1, hiding timestamp information.
|
|
125
|
+
*
|
|
126
|
+
* @param tnid The V0 TNID string to encrypt
|
|
127
|
+
* @param key The encryption key
|
|
128
|
+
* @returns The encrypted V1 TNID string
|
|
129
|
+
* @throws EncryptionError if the TNID is not V0 or is invalid
|
|
130
|
+
*/
|
|
131
|
+
export async function encryptV0ToV1(tnid, key) {
|
|
132
|
+
let value;
|
|
133
|
+
try {
|
|
134
|
+
value = tnidToValue(tnid);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
throw new EncryptionError(`Invalid TNID: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
const variant = extractVariantFromValue(value);
|
|
140
|
+
if (variant === "v1") {
|
|
141
|
+
// Already V1, return unchanged
|
|
142
|
+
return tnid;
|
|
143
|
+
}
|
|
144
|
+
if (variant !== "v0") {
|
|
145
|
+
throw new EncryptionError(`TNID variant ${variant} is not supported for encryption`);
|
|
146
|
+
}
|
|
147
|
+
// Extract the 100 Payload bits
|
|
148
|
+
const secretData = extractSecretDataBits(value);
|
|
149
|
+
// Encrypt the Payload
|
|
150
|
+
const encryptedData = await encryptPayload(secretData, key);
|
|
151
|
+
// Expand back to proper bit positions
|
|
152
|
+
const expanded = expandSecretDataBits(encryptedData);
|
|
153
|
+
// Preserve Name bits and UUID-specific bits, replace Payload bits
|
|
154
|
+
let result = (value & ~COMPLETE_SECRET_DATA_MASK) | expanded;
|
|
155
|
+
// Change variant from V0 to V1
|
|
156
|
+
result = setVariant(result, "v1");
|
|
157
|
+
return valueToTnid(result);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Decrypts a V1 TNID back to V0, recovering timestamp information.
|
|
161
|
+
*
|
|
162
|
+
* @param tnid The V1 TNID string to decrypt
|
|
163
|
+
* @param key The encryption key (must match the one used for encryption)
|
|
164
|
+
* @returns The decrypted V0 TNID string
|
|
165
|
+
* @throws EncryptionError if the TNID is not V1 or is invalid
|
|
166
|
+
*/
|
|
167
|
+
export async function decryptV1ToV0(tnid, key) {
|
|
168
|
+
let value;
|
|
169
|
+
try {
|
|
170
|
+
value = tnidToValue(tnid);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
throw new EncryptionError(`Invalid TNID: ${e.message}`);
|
|
174
|
+
}
|
|
175
|
+
const variant = extractVariantFromValue(value);
|
|
176
|
+
if (variant === "v0") {
|
|
177
|
+
// Already V0, return unchanged
|
|
178
|
+
return tnid;
|
|
179
|
+
}
|
|
180
|
+
if (variant !== "v1") {
|
|
181
|
+
throw new EncryptionError(`TNID variant ${variant} is not supported for decryption`);
|
|
182
|
+
}
|
|
183
|
+
// Extract the 100 Payload bits
|
|
184
|
+
const encryptedData = extractSecretDataBits(value);
|
|
185
|
+
// Decrypt the Payload
|
|
186
|
+
const decryptedData = await decryptPayload(encryptedData, key);
|
|
187
|
+
// Expand back to proper bit positions
|
|
188
|
+
const expanded = expandSecretDataBits(decryptedData);
|
|
189
|
+
// Preserve Name bits and UUID-specific bits, replace Payload bits
|
|
190
|
+
let result = (value & ~COMPLETE_SECRET_DATA_MASK) | expanded;
|
|
191
|
+
// Change variant from V1 to V0
|
|
192
|
+
result = setVariant(result, "v0");
|
|
193
|
+
return valueToTnid(result);
|
|
194
|
+
}
|
package/esm/ff1.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FF1 Format-Preserving Encryption (NIST SP 800-38G).
|
|
3
|
+
*
|
|
4
|
+
* This implementation uses AES-128 as the underlying cipher.
|
|
5
|
+
* FF1 is a Feistel cipher that encrypts strings of numerals
|
|
6
|
+
* while preserving their format (length and radix).
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* FF1 Format-Preserving Encryption cipher.
|
|
10
|
+
*
|
|
11
|
+
* Encrypts/decrypts a string of numerals (digits in base `radix`)
|
|
12
|
+
* while preserving the format.
|
|
13
|
+
*/
|
|
14
|
+
export declare class FF1 {
|
|
15
|
+
private aes;
|
|
16
|
+
private radix;
|
|
17
|
+
/**
|
|
18
|
+
* Create an FF1 cipher with the given key and radix.
|
|
19
|
+
*
|
|
20
|
+
* @param key 16-byte AES key
|
|
21
|
+
* @param radix Base of the numeral system (2-65536)
|
|
22
|
+
*/
|
|
23
|
+
constructor(key: Uint8Array, radix: number);
|
|
24
|
+
/**
|
|
25
|
+
* FF1 encryption.
|
|
26
|
+
*
|
|
27
|
+
* @param tweak Additional data (can be empty)
|
|
28
|
+
* @param plaintext Array of numerals (each in range [0, radix))
|
|
29
|
+
* @returns Encrypted numeral array of same length
|
|
30
|
+
*/
|
|
31
|
+
encrypt(tweak: Uint8Array, plaintext: number[]): Promise<number[]>;
|
|
32
|
+
/**
|
|
33
|
+
* FF1 decryption.
|
|
34
|
+
*
|
|
35
|
+
* @param tweak Additional data (must match encryption)
|
|
36
|
+
* @param ciphertext Array of numerals
|
|
37
|
+
* @returns Decrypted numeral array
|
|
38
|
+
*/
|
|
39
|
+
decrypt(tweak: Uint8Array, ciphertext: number[]): Promise<number[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Core FF1 Feistel cipher (10 rounds).
|
|
42
|
+
*/
|
|
43
|
+
private cipher;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=ff1.d.ts.map
|
package/esm/ff1.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ff1.d.ts","sourceRoot":"","sources":["../src/ff1.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA+EH;;;;;GAKG;AACH,qBAAa,GAAG;IACd,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;OAKG;gBACS,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM;IAQ1C;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAIlE;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAInE;;OAEG;YACW,MAAM;CAsIrB"}
|
package/esm/ff1.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FF1 Format-Preserving Encryption (NIST SP 800-38G).
|
|
3
|
+
*
|
|
4
|
+
* This implementation uses AES-128 as the underlying cipher.
|
|
5
|
+
* FF1 is a Feistel cipher that encrypts strings of numerals
|
|
6
|
+
* while preserving their format (length and radix).
|
|
7
|
+
*/
|
|
8
|
+
import { Aes128 } from "./aes.js";
|
|
9
|
+
/**
|
|
10
|
+
* Ceiling division: ceil(a / b)
|
|
11
|
+
*/
|
|
12
|
+
function ceilDiv(a, b) {
|
|
13
|
+
return Math.ceil(a / b);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Convert a number array (base radix) to a bigint.
|
|
17
|
+
* Most significant digit first.
|
|
18
|
+
*/
|
|
19
|
+
function numArrayToBigInt(arr, radix) {
|
|
20
|
+
let result = 0n;
|
|
21
|
+
const radixBig = BigInt(radix);
|
|
22
|
+
for (const digit of arr) {
|
|
23
|
+
result = result * radixBig + BigInt(digit);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Convert a bigint to a number array (base radix) with specified length.
|
|
29
|
+
* Most significant digit first. Pads with zeros if needed.
|
|
30
|
+
*/
|
|
31
|
+
function bigIntToNumArray(value, radix, length) {
|
|
32
|
+
const result = new Array(length).fill(0);
|
|
33
|
+
const radixBig = BigInt(radix);
|
|
34
|
+
let v = value;
|
|
35
|
+
for (let i = length - 1; i >= 0 && v > 0n; i--) {
|
|
36
|
+
result[i] = Number(v % radixBig);
|
|
37
|
+
v = v / radixBig;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Convert bigint to big-endian byte array with specified length.
|
|
43
|
+
*/
|
|
44
|
+
function bigIntToBytes(value, length) {
|
|
45
|
+
const bytes = new Uint8Array(length);
|
|
46
|
+
let v = value;
|
|
47
|
+
for (let i = length - 1; i >= 0 && v > 0n; i--) {
|
|
48
|
+
bytes[i] = Number(v & 0xffn);
|
|
49
|
+
v >>= 8n;
|
|
50
|
+
}
|
|
51
|
+
return bytes;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Convert byte array to bigint (big-endian).
|
|
55
|
+
*/
|
|
56
|
+
function bytesToBigInt(bytes) {
|
|
57
|
+
let result = 0n;
|
|
58
|
+
for (const byte of bytes) {
|
|
59
|
+
result = (result << 8n) | BigInt(byte);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Concatenate multiple Uint8Arrays.
|
|
65
|
+
*/
|
|
66
|
+
function concat(...arrays) {
|
|
67
|
+
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
|
68
|
+
const result = new Uint8Array(totalLength);
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const arr of arrays) {
|
|
71
|
+
result.set(arr, offset);
|
|
72
|
+
offset += arr.length;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* FF1 Format-Preserving Encryption cipher.
|
|
78
|
+
*
|
|
79
|
+
* Encrypts/decrypts a string of numerals (digits in base `radix`)
|
|
80
|
+
* while preserving the format.
|
|
81
|
+
*/
|
|
82
|
+
export class FF1 {
|
|
83
|
+
/**
|
|
84
|
+
* Create an FF1 cipher with the given key and radix.
|
|
85
|
+
*
|
|
86
|
+
* @param key 16-byte AES key
|
|
87
|
+
* @param radix Base of the numeral system (2-65536)
|
|
88
|
+
*/
|
|
89
|
+
constructor(key, radix) {
|
|
90
|
+
Object.defineProperty(this, "aes", {
|
|
91
|
+
enumerable: true,
|
|
92
|
+
configurable: true,
|
|
93
|
+
writable: true,
|
|
94
|
+
value: void 0
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(this, "radix", {
|
|
97
|
+
enumerable: true,
|
|
98
|
+
configurable: true,
|
|
99
|
+
writable: true,
|
|
100
|
+
value: void 0
|
|
101
|
+
});
|
|
102
|
+
if (radix < 2 || radix > 65536) {
|
|
103
|
+
throw new Error(`Radix must be in range [2, 65536], got ${radix}`);
|
|
104
|
+
}
|
|
105
|
+
this.aes = new Aes128(key);
|
|
106
|
+
this.radix = radix;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* FF1 encryption.
|
|
110
|
+
*
|
|
111
|
+
* @param tweak Additional data (can be empty)
|
|
112
|
+
* @param plaintext Array of numerals (each in range [0, radix))
|
|
113
|
+
* @returns Encrypted numeral array of same length
|
|
114
|
+
*/
|
|
115
|
+
encrypt(tweak, plaintext) {
|
|
116
|
+
return this.cipher(tweak, plaintext, true);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* FF1 decryption.
|
|
120
|
+
*
|
|
121
|
+
* @param tweak Additional data (must match encryption)
|
|
122
|
+
* @param ciphertext Array of numerals
|
|
123
|
+
* @returns Decrypted numeral array
|
|
124
|
+
*/
|
|
125
|
+
decrypt(tweak, ciphertext) {
|
|
126
|
+
return this.cipher(tweak, ciphertext, false);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Core FF1 Feistel cipher (10 rounds).
|
|
130
|
+
*/
|
|
131
|
+
async cipher(tweak, input, encrypting) {
|
|
132
|
+
const n = input.length;
|
|
133
|
+
const t = tweak.length;
|
|
134
|
+
const radix = this.radix;
|
|
135
|
+
// Split into two halves
|
|
136
|
+
const u = Math.floor(n / 2);
|
|
137
|
+
const v = n - u;
|
|
138
|
+
// A gets first u elements, B gets last v elements
|
|
139
|
+
let A = input.slice(0, u);
|
|
140
|
+
let B = input.slice(u);
|
|
141
|
+
// Precompute constants per spec
|
|
142
|
+
// b = ceil(ceil(v * log2(radix)) / 8) - number of bytes to represent NUM_radix(B)
|
|
143
|
+
const b = Math.ceil(Math.ceil(v * Math.log2(radix)) / 8);
|
|
144
|
+
// d = 4 * ceil(b/4) + 4 - length of S (multiple of 4, at least 4 more than b)
|
|
145
|
+
const d = 4 * ceilDiv(b, 4) + 4;
|
|
146
|
+
// P = [1, 2, 1, radix (3 bytes), 10, u mod 256, n (4 bytes), t (4 bytes)]
|
|
147
|
+
const P = new Uint8Array(16);
|
|
148
|
+
P[0] = 1;
|
|
149
|
+
P[1] = 2;
|
|
150
|
+
P[2] = 1;
|
|
151
|
+
// radix as 3 bytes (big-endian, upper 8 bits then lower 16 bits)
|
|
152
|
+
P[3] = (radix >> 16) & 0xff;
|
|
153
|
+
P[4] = (radix >> 8) & 0xff;
|
|
154
|
+
P[5] = radix & 0xff;
|
|
155
|
+
P[6] = 10; // Number of rounds
|
|
156
|
+
P[7] = u & 0xff;
|
|
157
|
+
// n as 4 bytes big-endian
|
|
158
|
+
P[8] = (n >> 24) & 0xff;
|
|
159
|
+
P[9] = (n >> 16) & 0xff;
|
|
160
|
+
P[10] = (n >> 8) & 0xff;
|
|
161
|
+
P[11] = n & 0xff;
|
|
162
|
+
// t as 4 bytes big-endian
|
|
163
|
+
P[12] = (t >> 24) & 0xff;
|
|
164
|
+
P[13] = (t >> 16) & 0xff;
|
|
165
|
+
P[14] = (t >> 8) & 0xff;
|
|
166
|
+
P[15] = t & 0xff;
|
|
167
|
+
// 10 Feistel rounds
|
|
168
|
+
const rounds = encrypting ? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] : [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
|
|
169
|
+
for (const i of rounds) {
|
|
170
|
+
// Determine m based on round parity
|
|
171
|
+
const m = (i % 2 === 0) ? u : v;
|
|
172
|
+
// Build Q: tweak || zeros || round || numB
|
|
173
|
+
// Q length must be multiple of 16
|
|
174
|
+
const numB = numArrayToBigInt(encrypting ? B : A, radix);
|
|
175
|
+
const numBBytes = bigIntToBytes(numB, b);
|
|
176
|
+
// Q = tweak || 0^(-t-b-1 mod 16) || i || [NUM_radix(B)]
|
|
177
|
+
const padLen = (16 - ((t + b + 1) % 16)) % 16;
|
|
178
|
+
const Q = concat(tweak, new Uint8Array(padLen), new Uint8Array([i]), numBBytes);
|
|
179
|
+
// R = PRF(P || Q)
|
|
180
|
+
// PRF uses AES-CBC-MAC over (P || Q) with blocks of 16 bytes
|
|
181
|
+
const pq = concat(P, Q);
|
|
182
|
+
const numBlocks = pq.length / 16;
|
|
183
|
+
const blocks = [];
|
|
184
|
+
for (let j = 0; j < numBlocks; j++) {
|
|
185
|
+
blocks.push(pq.slice(j * 16, (j + 1) * 16));
|
|
186
|
+
}
|
|
187
|
+
const R = await this.aes.cbcMac(blocks);
|
|
188
|
+
// S = first d bytes of R || CIPH(R ⊕ [1]) || CIPH(R ⊕ [2]) || ...
|
|
189
|
+
const S = new Uint8Array(d);
|
|
190
|
+
const rCopyLen = Math.min(16, d);
|
|
191
|
+
S.set(R.slice(0, rCopyLen), 0);
|
|
192
|
+
let sOffset = rCopyLen;
|
|
193
|
+
let counter = 1;
|
|
194
|
+
while (sOffset < d) {
|
|
195
|
+
// R XOR counter (counter as 16-byte big-endian)
|
|
196
|
+
const counterBlock = new Uint8Array(16);
|
|
197
|
+
counterBlock[15] = counter & 0xff;
|
|
198
|
+
counterBlock[14] = (counter >> 8) & 0xff;
|
|
199
|
+
counterBlock[13] = (counter >> 16) & 0xff;
|
|
200
|
+
counterBlock[12] = (counter >> 24) & 0xff;
|
|
201
|
+
const xored = new Uint8Array(16);
|
|
202
|
+
for (let k = 0; k < 16; k++) {
|
|
203
|
+
xored[k] = R[k] ^ counterBlock[k];
|
|
204
|
+
}
|
|
205
|
+
const block = await this.aes.encryptBlock(xored);
|
|
206
|
+
const copyLen = Math.min(16, d - sOffset);
|
|
207
|
+
S.set(block.slice(0, copyLen), sOffset);
|
|
208
|
+
sOffset += copyLen;
|
|
209
|
+
counter++;
|
|
210
|
+
}
|
|
211
|
+
// y = NUM(S) - interpret S as big-endian number
|
|
212
|
+
const y = bytesToBigInt(S);
|
|
213
|
+
// c = (NUM_radix(A/B) + y) mod radix^m (encrypt)
|
|
214
|
+
// c = (NUM_radix(A/B) - y) mod radix^m (decrypt)
|
|
215
|
+
const radixPowM = BigInt(radix) ** BigInt(m);
|
|
216
|
+
const numSrc = numArrayToBigInt(encrypting ? A : B, radix);
|
|
217
|
+
let c;
|
|
218
|
+
if (encrypting) {
|
|
219
|
+
c = (numSrc + y) % radixPowM;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// For modular subtraction, add radixPowM to handle negative
|
|
223
|
+
c = ((numSrc - y) % radixPowM + radixPowM) % radixPowM;
|
|
224
|
+
}
|
|
225
|
+
// C = STR_m_radix(c)
|
|
226
|
+
const C = bigIntToNumArray(c, radix, m);
|
|
227
|
+
// Swap: A = B, B = C (encrypt) or B = A, A = C (decrypt)
|
|
228
|
+
if (encrypting) {
|
|
229
|
+
A = B;
|
|
230
|
+
B = C;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
B = A;
|
|
234
|
+
A = C;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Return A || B
|
|
238
|
+
return [...A, ...B];
|
|
239
|
+
}
|
|
240
|
+
}
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tnid/encryption - Format-preserving encryption for TNIDs
|
|
3
|
+
*
|
|
4
|
+
* Provides FF1 (NIST SP 800-38G) encryption to convert time-ordered V0 TNIDs
|
|
5
|
+
* to random-looking V1 TNIDs, hiding timestamp information while remaining
|
|
6
|
+
* reversible with the secret key.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { EncryptionKey, encryptV0ToV1, decryptV1ToV0 } from "@tnid/encryption";
|
|
11
|
+
*
|
|
12
|
+
* // Create a key from hex string
|
|
13
|
+
* const key = EncryptionKey.fromHex("0102030405060708090a0b0c0d0e0f10");
|
|
14
|
+
*
|
|
15
|
+
* // Encrypt a V0 TNID to V1
|
|
16
|
+
* const encrypted = await encryptV0ToV1("user.Br2flcNDfF6LYICnT", key);
|
|
17
|
+
* // Returns a V1 TNID that looks random
|
|
18
|
+
*
|
|
19
|
+
* // Decrypt back to V0
|
|
20
|
+
* const decrypted = await decryptV1ToV0(encrypted, key);
|
|
21
|
+
* // decrypted === "user.Br2flcNDfF6LYICnT"
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
export { EncryptionKey, EncryptionKeyError, EncryptionError, encryptV0ToV1, decryptV1ToV0, } from "./encryption.js";
|
|
27
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EACL,aAAa,EACb,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,aAAa,GACd,MAAM,iBAAiB,CAAC"}
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tnid/encryption - Format-preserving encryption for TNIDs
|
|
3
|
+
*
|
|
4
|
+
* Provides FF1 (NIST SP 800-38G) encryption to convert time-ordered V0 TNIDs
|
|
5
|
+
* to random-looking V1 TNIDs, hiding timestamp information while remaining
|
|
6
|
+
* reversible with the secret key.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { EncryptionKey, encryptV0ToV1, decryptV1ToV0 } from "@tnid/encryption";
|
|
11
|
+
*
|
|
12
|
+
* // Create a key from hex string
|
|
13
|
+
* const key = EncryptionKey.fromHex("0102030405060708090a0b0c0d0e0f10");
|
|
14
|
+
*
|
|
15
|
+
* // Encrypt a V0 TNID to V1
|
|
16
|
+
* const encrypted = await encryptV0ToV1("user.Br2flcNDfF6LYICnT", key);
|
|
17
|
+
* // Returns a V1 TNID that looks random
|
|
18
|
+
*
|
|
19
|
+
* // Decrypt back to V0
|
|
20
|
+
* const decrypted = await decryptV1ToV0(encrypted, key);
|
|
21
|
+
* // decrypted === "user.Br2flcNDfF6LYICnT"
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
export { EncryptionKey, EncryptionKeyError, EncryptionError, encryptV0ToV1, decryptV1ToV0, } from "./encryption.js";
|
package/esm/package.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tnid/encryption",
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"description": "Format-preserving encryption for TNIDs - convert time-ordered IDs to random-looking IDs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"uuid",
|
|
7
|
+
"id",
|
|
8
|
+
"identifier",
|
|
9
|
+
"tnid",
|
|
10
|
+
"typed",
|
|
11
|
+
"type-safe"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/tnid/tnid-typescript.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/tnid/tnid-typescript/issues"
|
|
20
|
+
},
|
|
21
|
+
"main": "./script/index.js",
|
|
22
|
+
"module": "./esm/index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./esm/index.js",
|
|
26
|
+
"require": "./script/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@tnid/core": "^0.0.4"
|
|
38
|
+
},
|
|
39
|
+
"_generatedBy": "dnt@dev"
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_dnt.shims.d.ts","sourceRoot":"","sources":["../src/_dnt.shims.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,aAAa,gCAA2C,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dntGlobalThis = void 0;
|
|
4
|
+
const dntGlobals = {};
|
|
5
|
+
exports.dntGlobalThis = createMergeProxy(globalThis, dntGlobals);
|
|
6
|
+
function createMergeProxy(baseObj, extObj) {
|
|
7
|
+
return new Proxy(baseObj, {
|
|
8
|
+
get(_target, prop, _receiver) {
|
|
9
|
+
if (prop in extObj) {
|
|
10
|
+
return extObj[prop];
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
return baseObj[prop];
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
set(_target, prop, value) {
|
|
17
|
+
if (prop in extObj) {
|
|
18
|
+
delete extObj[prop];
|
|
19
|
+
}
|
|
20
|
+
baseObj[prop] = value;
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
deleteProperty(_target, prop) {
|
|
24
|
+
let success = false;
|
|
25
|
+
if (prop in extObj) {
|
|
26
|
+
delete extObj[prop];
|
|
27
|
+
success = true;
|
|
28
|
+
}
|
|
29
|
+
if (prop in baseObj) {
|
|
30
|
+
delete baseObj[prop];
|
|
31
|
+
success = true;
|
|
32
|
+
}
|
|
33
|
+
return success;
|
|
34
|
+
},
|
|
35
|
+
ownKeys(_target) {
|
|
36
|
+
const baseKeys = Reflect.ownKeys(baseObj);
|
|
37
|
+
const extKeys = Reflect.ownKeys(extObj);
|
|
38
|
+
const extKeysSet = new Set(extKeys);
|
|
39
|
+
return [...baseKeys.filter((k) => !extKeysSet.has(k)), ...extKeys];
|
|
40
|
+
},
|
|
41
|
+
defineProperty(_target, prop, desc) {
|
|
42
|
+
if (prop in extObj) {
|
|
43
|
+
delete extObj[prop];
|
|
44
|
+
}
|
|
45
|
+
Reflect.defineProperty(baseObj, prop, desc);
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
49
|
+
if (prop in extObj) {
|
|
50
|
+
return Reflect.getOwnPropertyDescriptor(extObj, prop);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
return Reflect.getOwnPropertyDescriptor(baseObj, prop);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
has(_target, prop) {
|
|
57
|
+
return prop in extObj || prop in baseObj;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
package/script/aes.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-128 block cipher wrapper using Web Crypto API.
|
|
3
|
+
*
|
|
4
|
+
* FF1 requires raw AES block cipher (AES-ECB), which Web Crypto doesn't expose.
|
|
5
|
+
* We simulate AES-ECB using AES-CBC with a zero IV for single-block operations.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* AES-128 block cipher for FF1.
|
|
9
|
+
* Caches the imported CryptoKey for efficiency.
|
|
10
|
+
*/
|
|
11
|
+
export declare class Aes128 {
|
|
12
|
+
private keyPromise;
|
|
13
|
+
constructor(keyBytes: Uint8Array);
|
|
14
|
+
/**
|
|
15
|
+
* Encrypts a single 16-byte block using AES-128.
|
|
16
|
+
* Uses AES-CBC with zero IV, which is equivalent to AES-ECB for single blocks.
|
|
17
|
+
*/
|
|
18
|
+
encryptBlock(block: Uint8Array): Promise<Uint8Array>;
|
|
19
|
+
/**
|
|
20
|
+
* AES-CBC-MAC: Chain multiple blocks using CBC mode.
|
|
21
|
+
* Returns the final encrypted block (the MAC).
|
|
22
|
+
*/
|
|
23
|
+
cbcMac(blocks: Uint8Array[]): Promise<Uint8Array>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=aes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aes.d.ts","sourceRoot":"","sources":["../src/aes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH;;;GAGG;AACH,qBAAa,MAAM;IACjB,OAAO,CAAC,UAAU,CAAqB;gBAE3B,QAAQ,EAAE,UAAU;IAchC;;;OAGG;IACG,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAmB1D;;;OAGG;IACG,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,UAAU,CAAC;CAgBxD"}
|