Last active
January 7, 2026 06:44
-
-
Save bouroo/ca8a41ac876f713fa8c64cb37c7b36a9 to your computer and use it in GitHub Desktop.
Thai National ID Card reader in TypeScript with Bun runtime
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Thai National ID Card Reader | |
| * Optimized for Bun Runtime with TypeScript | |
| * | |
| * @author Kawin Viriyaprasopsook <kawin.vir@zercle.tech> | |
| * @refactored for TypeScript + Bun | |
| */ | |
| import { Devices, Card, Device } from 'smartcard'; | |
| // ============================================================================ | |
| // Types | |
| // ============================================================================ | |
| type Command = readonly number[]; | |
| type PhotoChunk = { idx: number; chunk: string }; | |
| interface CardData { | |
| cid: string; | |
| thFullname: string; | |
| enFullname: string; | |
| dateOfBirth: string; | |
| gender: string; | |
| issuer: string; | |
| issueDate: string; | |
| expireDate: string; | |
| address: string; | |
| photo?: string; | |
| } | |
| interface DataField { | |
| readonly cmd: Command; | |
| readonly label: string; | |
| readonly key: keyof CardData; | |
| } | |
| interface CardInsertedEvent { | |
| card: Card; | |
| device: Device; | |
| } | |
| interface DeviceActivatedEvent { | |
| device: Device; | |
| devices: readonly Device[]; | |
| } | |
| interface DeviceDeactivatedEvent { | |
| device: Device; | |
| devices: readonly Device[]; | |
| } | |
| // ============================================================================ | |
| // Constants | |
| // ============================================================================ | |
| const DEFAULT_REQ: Command = [0x00, 0xC0, 0x00, 0x00]; | |
| const ALT_REQ: Command = [0x00, 0xC0, 0x00, 0x01]; | |
| const PHOTO_CHUNKS = 15; | |
| const COMMANDS = { | |
| SELECT_APP: [0x00, 0xA4, 0x04, 0x00, 0x08, 0xA0, 0x00, 0x00, 0x00, 0x54, 0x48, 0x00, 0x01] as const, | |
| CID: [0x80, 0xB0, 0x00, 0x04, 0x02, 0x00, 0x0D] as const, | |
| TH_NAME: [0x80, 0xB0, 0x00, 0x11, 0x02, 0x00, 0x64] as const, | |
| EN_NAME: [0x80, 0xB0, 0x00, 0x75, 0x02, 0x00, 0x64] as const, | |
| DOB: [0x80, 0xB0, 0x00, 0xD9, 0x02, 0x00, 0x08] as const, | |
| GENDER: [0x80, 0xB0, 0x00, 0xE1, 0x02, 0x00, 0x01] as const, | |
| ISSUER: [0x80, 0xB0, 0x00, 0xF6, 0x02, 0x00, 0x64] as const, | |
| ISSUE_DATE: [0x80, 0xB0, 0x01, 0x67, 0x02, 0x00, 0x08] as const, | |
| EXPIRY_DATE: [0x80, 0xB0, 0x01, 0x6F, 0x02, 0x00, 0x08] as const, | |
| ADDRESS: [0x80, 0xB0, 0x15, 0x79, 0x02, 0x00, 0x64] as const, | |
| PHOTO_CHUNK: (idx: number): Command => [0x80, 0xB0, idx + 1, 0x7B - idx, 0x02, 0x00, 0xFF] as const, | |
| } as const; | |
| const DATA_FIELDS: readonly DataField[] = [ | |
| { cmd: COMMANDS.CID, label: 'CID', key: 'cid' }, | |
| { cmd: COMMANDS.TH_NAME, label: 'Thai Name', key: 'thFullname' }, | |
| { cmd: COMMANDS.EN_NAME, label: 'English Name', key: 'enFullname' }, | |
| { cmd: COMMANDS.DOB, label: 'Date of Birth', key: 'dateOfBirth' }, | |
| { cmd: COMMANDS.GENDER, label: 'Gender', key: 'gender' }, | |
| { cmd: COMMANDS.ISSUER, label: 'Issuer', key: 'issuer' }, | |
| { cmd: COMMANDS.ISSUE_DATE, label: 'Issue Date', key: 'issueDate' }, | |
| { cmd: COMMANDS.EXPIRY_DATE, label: 'Expiry Date', key: 'expireDate' }, | |
| { cmd: COMMANDS.ADDRESS, label: 'Address', key: 'address' }, | |
| ] as const; | |
| // ============================================================================ | |
| // Utilities | |
| // ============================================================================ | |
| class TextDecoderISO8859_11 { | |
| private static readonly decodeTable = new TextDecoder('iso-8859-11'); | |
| static decode(buffer: Uint8Array): string { | |
| return this.decodeTable.decode(buffer); | |
| } | |
| } | |
| function hexToBase64(hexString: string): string { | |
| const bytes = new Uint8Array( | |
| hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) ?? [] | |
| ); | |
| return btoa(String.fromCharCode(...bytes)); | |
| } | |
| // ============================================================================ | |
| // Card Session | |
| // ============================================================================ | |
| class CardSession { | |
| private readonly reqHeader: Command; | |
| private readonly decoder = TextDecoderISO8859_11; | |
| constructor(private readonly card: Card) { | |
| const atr = this.card.getAtr(); | |
| this.reqHeader = atr.startsWith('3b67') ? ALT_REQ : DEFAULT_REQ; | |
| } | |
| async run(): Promise<CardData> { | |
| await this.issueCommand(COMMANDS.SELECT_APP); | |
| const cardData: Partial<CardData> = {}; | |
| // Fetch all data fields in parallel | |
| const fieldPromises = DATA_FIELDS.map(async ({ cmd, label, key }) => { | |
| const data = await this.fetch(cmd); | |
| const decoded = this.decoder.decode(data.slice(0, -2)); | |
| return { key, value: decoded, label }; | |
| }); | |
| const results = await Promise.all(fieldPromises); | |
| results.forEach(({ key, value, label }) => { | |
| cardData[key] = value; | |
| console.log(`${label}: ${value}`); | |
| }); | |
| // Fetch photo | |
| cardData.photo = await this.fetchPhoto(); | |
| return cardData as CardData; | |
| } | |
| private async fetch(cmd: Command): Promise<Uint8Array> { | |
| await this.issueCommand(cmd); | |
| const le = cmd[cmd.length - 1]; | |
| return this.issueCommand([...this.reqHeader, le]); | |
| } | |
| private async fetchPhoto(): Promise<string> { | |
| const promises = Array.from( | |
| { length: PHOTO_CHUNKS }, | |
| (_, i) => this.fetchChunk(i) | |
| ); | |
| const chunks = await Promise.all(promises); | |
| const hexPhoto = chunks | |
| .sort((a, b) => a.idx - b.idx) | |
| .map(p => p.chunk) | |
| .join(''); | |
| const base64Photo = `data:image/jpg;base64,${hexToBase64(hexPhoto)}`; | |
| console.log(`Photo: ${base64Photo.substring(0, 50)}...`); | |
| return base64Photo; | |
| } | |
| private async fetchChunk(idx: number): Promise<PhotoChunk> { | |
| const buf = await this.fetch(COMMANDS.PHOTO_CHUNK(idx)); | |
| return { | |
| idx, | |
| chunk: Buffer.from(buf).toString('hex').slice(0, -4), | |
| }; | |
| } | |
| private async issueCommand(apdu: Command): Promise<Uint8Array> { | |
| return this.card.issueCommand(apdu); | |
| } | |
| } | |
| // ============================================================================ | |
| // Device Manager | |
| // ============================================================================ | |
| class DeviceManager { | |
| private readonly devices: Devices; | |
| constructor( | |
| private readonly onCardRead?: (data: CardData) => void | Promise<void>, | |
| private readonly onError?: (error: Error) => void | |
| ) { | |
| this.devices = new Devices(); | |
| this.bindEvents(); | |
| } | |
| private bindEvents(): void { | |
| this.devices.on('device-activated', this.handleDeviceActivated.bind(this)); | |
| this.devices.on('device-deactivated', this.handleDeviceDeactivated.bind(this)); | |
| } | |
| private handleDeviceActivated({ device, devices }: DeviceActivatedEvent): void { | |
| console.log(`Device '${device}' activated. Total devices: ${devices.length}`); | |
| device.on('card-inserted', this.handleCardInserted.bind(this)); | |
| device.on('card-removed', this.handleCardRemoved.bind(this)); | |
| } | |
| private async handleCardInserted({ card, device }: CardInsertedEvent): Promise<void> { | |
| const atr = card.getAtr(); | |
| console.log(`Card inserted (ATR: ${atr}) into device '${device}'`); | |
| try { | |
| const session = new CardSession(card); | |
| const cardData = await session.run(); | |
| console.log('✓ Card read successfully'); | |
| if (this.onCardRead) { | |
| await this.onCardRead(cardData); | |
| } | |
| } catch (error) { | |
| const err = error instanceof Error ? error : new Error(String(error)); | |
| console.error('✗ Failed to read card:', err.message); | |
| if (this.onError) { | |
| this.onError(err); | |
| } | |
| } | |
| } | |
| private handleCardRemoved({ name }: { name: string }): void { | |
| console.log(`Card removed from device '${name}'`); | |
| } | |
| private handleDeviceDeactivated({ device, devices }: DeviceDeactivatedEvent): void { | |
| console.log(`Device '${device}' deactivated. Remaining devices: ${devices.length}`); | |
| } | |
| } | |
| // ============================================================================ | |
| // Example Usage / Entry Point | |
| // ============================================================================ | |
| export { CardData, CardSession, DeviceManager }; | |
| // If running as main script | |
| if (import.meta.main) { | |
| const manager = new DeviceManager( | |
| async (data) => { | |
| console.log('\n📋 Card Data Summary:'); | |
| console.log(JSON.stringify(data, null, 2)); | |
| }, | |
| (error) => { | |
| console.error('Card reader error:', error); | |
| } | |
| ); | |
| // Graceful shutdown | |
| const shutdown = () => { | |
| console.log('\nShutting down...'); | |
| process.exit(0); | |
| }; | |
| process.on('SIGINT', shutdown); | |
| process.on('SIGTERM', shutdown); | |
| } | |
| export default DeviceManager; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment