|
| 1 | +// SubDomain.ts |
| 2 | + |
| 3 | +import baseX from "base-x"; |
| 4 | +import crypto from "crypto"; |
| 5 | + |
| 6 | +/** |
| 7 | + * SubDomain Class |
| 8 | + * |
| 9 | + * Encapsulates the logic for encoding and decoding a combination of |
| 10 | + * chain and storeId into a DNS-friendly identifier using Base62 encoding and HMAC. |
| 11 | + */ |
| 12 | +class SubDomain { |
| 13 | + // Define the Base62 character set |
| 14 | + private static readonly BASE62_CHARSET = |
| 15 | + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; |
| 16 | + |
| 17 | + // Initialize the Base62 encoder/decoder |
| 18 | + private static base62 = baseX(SubDomain.BASE62_CHARSET); |
| 19 | + |
| 20 | + // Define expected byte length for storeId |
| 21 | + private static readonly DEFAULT_STORE_ID_LENGTH = 32; // bytes |
| 22 | + private static readonly HMAC_LENGTH = 32; // bytes for HMAC-SHA256 |
| 23 | + |
| 24 | + // Hardcoded compression key |
| 25 | + private static readonly COMPRESSION_KEY = |
| 26 | + "7a4e8d2f6b1c9a3f5d8e2c4b7a1f9d3e6b8c5a2f4d7e9b1c8a3f5d2e6b9c4a7"; |
| 27 | + |
| 28 | + // Properties |
| 29 | + public readonly chain: string; |
| 30 | + public readonly storeId: string; |
| 31 | + public readonly encodedId: string; |
| 32 | + |
| 33 | + /** |
| 34 | + * Constructor for SubDomain |
| 35 | + * |
| 36 | + * @param chain - The chain name (e.g., "CHAIN1234"). |
| 37 | + * @param storeId - The store ID as a 64-character hexadecimal string. |
| 38 | + * @throws Will throw an error if inputs are invalid or encoding exceeds DNS limits. |
| 39 | + */ |
| 40 | + constructor(chain: string, storeId: string) { |
| 41 | + this.chain = chain; |
| 42 | + this.storeId = storeId; |
| 43 | + this.encodedId = this.encode(); |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * Encodes the provided chain and storeId into a DNS-friendly identifier with HMAC. |
| 48 | + * |
| 49 | + * @returns The Base62-encoded identifier with appended HMAC. |
| 50 | + * @throws Will throw an error if encoding fails. |
| 51 | + */ |
| 52 | + private encode(): string { |
| 53 | + // Validate inputs |
| 54 | + if (!this.chain || typeof this.chain !== "string") { |
| 55 | + throw new Error("Invalid chain: Chain must be a non-empty string."); |
| 56 | + } |
| 57 | + |
| 58 | + if ( |
| 59 | + !this.storeId || |
| 60 | + typeof this.storeId !== "string" || |
| 61 | + !/^[0-9a-fA-F]{64}$/.test(this.storeId) |
| 62 | + ) { |
| 63 | + throw new Error( |
| 64 | + "Invalid storeId: StoreId must be a 64-character hexadecimal string." |
| 65 | + ); |
| 66 | + } |
| 67 | + |
| 68 | + // Ensure the chain length is within 1-255 characters to fit in one byte |
| 69 | + const chainLength = this.chain.length; |
| 70 | + if (chainLength < 1 || chainLength > 255) { |
| 71 | + throw new Error( |
| 72 | + "Invalid chain: Length must be between 1 and 255 characters." |
| 73 | + ); |
| 74 | + } |
| 75 | + |
| 76 | + // Convert chain length to a single byte Buffer |
| 77 | + const chainLengthBuffer = Buffer.from([chainLength]); |
| 78 | + |
| 79 | + // Convert chain to a Buffer (UTF-8) |
| 80 | + const chainBuffer = Buffer.from(this.chain, "utf8"); |
| 81 | + |
| 82 | + // Convert storeId from hex string to Buffer |
| 83 | + const storeIdBuffer = Buffer.from(this.storeId, "hex"); |
| 84 | + |
| 85 | + // Validate storeId byte length |
| 86 | + if (storeIdBuffer.length !== SubDomain.DEFAULT_STORE_ID_LENGTH) { |
| 87 | + throw new Error( |
| 88 | + `Invalid storeId length: Expected ${SubDomain.DEFAULT_STORE_ID_LENGTH} bytes, got ${storeIdBuffer.length} bytes.` |
| 89 | + ); |
| 90 | + } |
| 91 | + |
| 92 | + // Concatenate chain_length, chain, and storeId buffers |
| 93 | + const dataBuffer = Buffer.concat([ |
| 94 | + chainLengthBuffer, |
| 95 | + chainBuffer, |
| 96 | + storeIdBuffer, |
| 97 | + ]); |
| 98 | + |
| 99 | + // Create HMAC using SHA256 |
| 100 | + const hmac = crypto.createHmac("sha256", SubDomain.COMPRESSION_KEY); |
| 101 | + hmac.update(dataBuffer); |
| 102 | + const hmacDigest = hmac.digest(); // 32 bytes |
| 103 | + |
| 104 | + // Concatenate dataBuffer and hmacDigest |
| 105 | + const finalBuffer = Buffer.concat([dataBuffer, hmacDigest]); |
| 106 | + |
| 107 | + // Encode the final buffer using Base62 |
| 108 | + const encodedId = SubDomain.base62.encode(finalBuffer); |
| 109 | + |
| 110 | + // Ensure DNS label length does not exceed 63 characters |
| 111 | + if (encodedId.length > 63) { |
| 112 | + throw new Error( |
| 113 | + `Encoded identifier length (${encodedId.length}) exceeds DNS label limit of 63 characters.` |
| 114 | + ); |
| 115 | + } |
| 116 | + |
| 117 | + return encodedId; |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * Decodes the provided identifier back into the original chain and storeId after verifying HMAC. |
| 122 | + * |
| 123 | + * @param encodedId - The Base62-encoded identifier with appended HMAC. |
| 124 | + * @returns An object containing the original chain and storeId. |
| 125 | + * @throws Will throw an error if decoding fails, HMAC verification fails, or data lengths mismatch. |
| 126 | + */ |
| 127 | + public static decode(encodedId: string): { chain: string; storeId: string } { |
| 128 | + // Validate input |
| 129 | + if (!encodedId || typeof encodedId !== "string") { |
| 130 | + throw new Error( |
| 131 | + "Invalid encodedId: encodedId must be a non-empty string." |
| 132 | + ); |
| 133 | + } |
| 134 | + |
| 135 | + // Decode the Base62 string back to a Buffer |
| 136 | + const decodedBuffer = SubDomain.base62.decode(encodedId); |
| 137 | + |
| 138 | + if (!decodedBuffer) { |
| 139 | + throw new Error("Failed to decode Base62 string."); |
| 140 | + } |
| 141 | + |
| 142 | + // Ensure there's at least 1 byte for chain_length and 64 bytes for storeId and HMAC |
| 143 | + if ( |
| 144 | + decodedBuffer.length < |
| 145 | + 1 + SubDomain.DEFAULT_STORE_ID_LENGTH + SubDomain.HMAC_LENGTH |
| 146 | + ) { |
| 147 | + throw new Error("Decoded data is too short to contain required fields."); |
| 148 | + } |
| 149 | + |
| 150 | + // Extract chain_length (1 byte) |
| 151 | + const chain_length = Buffer.from(decodedBuffer).readUInt8(0); |
| 152 | + |
| 153 | + // Define the expected total length |
| 154 | + const expected_length = |
| 155 | + 1 + |
| 156 | + chain_length + |
| 157 | + SubDomain.DEFAULT_STORE_ID_LENGTH + |
| 158 | + SubDomain.HMAC_LENGTH; |
| 159 | + |
| 160 | + if (decodedBuffer.length !== expected_length) { |
| 161 | + throw new Error( |
| 162 | + `Decoded data length mismatch: expected ${expected_length} bytes, got ${decodedBuffer.length} bytes.` |
| 163 | + ); |
| 164 | + } |
| 165 | + |
| 166 | + // Extract chain, storeId, and received HMAC from the buffer |
| 167 | + const chain = Buffer.from( |
| 168 | + decodedBuffer.slice(1, 1 + chain_length) |
| 169 | + ).toString("utf8"); |
| 170 | + const storeIdBuffer = decodedBuffer.slice( |
| 171 | + 1 + chain_length, |
| 172 | + 1 + chain_length + SubDomain.DEFAULT_STORE_ID_LENGTH |
| 173 | + ); |
| 174 | + const receivedHmac = decodedBuffer.slice( |
| 175 | + 1 + chain_length + SubDomain.DEFAULT_STORE_ID_LENGTH, |
| 176 | + expected_length |
| 177 | + ); |
| 178 | + |
| 179 | + // Recompute HMAC over [chain_length][chain][storeId] |
| 180 | + const dataBuffer = decodedBuffer.slice( |
| 181 | + 0, |
| 182 | + 1 + chain_length + SubDomain.DEFAULT_STORE_ID_LENGTH |
| 183 | + ); |
| 184 | + const hmac = crypto.createHmac("sha256", SubDomain.COMPRESSION_KEY); |
| 185 | + hmac.update(dataBuffer); |
| 186 | + const expectedHmac = hmac.digest(); // 32 bytes |
| 187 | + |
| 188 | + // Compare HMACs securely |
| 189 | + if (!crypto.timingSafeEqual(receivedHmac, expectedHmac)) { |
| 190 | + throw new Error("HMAC verification failed: Invalid identifier."); |
| 191 | + } |
| 192 | + |
| 193 | + // Convert storeId buffer to hex string |
| 194 | + const storeId = Buffer.from(storeIdBuffer).toString("hex"); |
| 195 | + |
| 196 | + return { chain, storeId }; |
| 197 | + } |
| 198 | +} |
| 199 | + |
| 200 | +export { SubDomain }; |
0 commit comments