Last active
January 7, 2026 06:50
-
-
Save bouroo/a3aa857727d909283c33e7b00623b48d to your computer and use it in GitHub Desktop.
Thai National ID Card reader in GO
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
| package main | |
| import ( | |
| "bytes" | |
| "context" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "log" | |
| "os" | |
| "strconv" | |
| "strings" | |
| "time" | |
| "github.com/ebfe/scard" | |
| "golang.org/x/text/encoding/charmap" | |
| "golang.org/x/text/transform" | |
| ) | |
| // ============================================================================ | |
| // Custom Errors | |
| // ============================================================================ | |
| var ( | |
| ErrNoReadersAvailable = errors.New("no smart card readers available") | |
| ErrInvalidReaderIndex = errors.New("invalid reader selection") | |
| ErrInvalidCardResponse = errors.New("invalid card response") | |
| ErrInsufficientData = errors.New("insufficient data returned from card") | |
| ) | |
| // CardError wraps smart card operation errors with context | |
| type CardError struct { | |
| Op string | |
| Err error | |
| } | |
| func (e *CardError) Error() string { | |
| return fmt.Sprintf("card %s failed: %v", e.Op, e.Err) | |
| } | |
| func (e *CardError) Unwrap() error { | |
| return e.Err | |
| } | |
| // ============================================================================ | |
| // Interfaces | |
| // ============================================================================ | |
| // ReaderSelector selects a smart card reader from available options | |
| type ReaderSelector interface { | |
| Select(ctx context.Context, readers []string) (int, error) | |
| } | |
| // CardConnector establishes connections to smart cards | |
| type CardConnector interface { | |
| Connect(ctx context.Context, scardCtx *scard.Context, reader string) (*scard.Card, error) | |
| } | |
| // SmartCardService provides smart card operations | |
| type SmartCardService interface { | |
| Status(ctx context.Context) (scard.Status, error) | |
| ATR(ctx context.Context) ([]byte, error) | |
| AdjustRequest(atr []byte) | |
| Transmit(ctx context.Context, cmd []byte) ([]byte, error) | |
| GetData(ctx context.Context, cmd []byte) ([]byte, error) | |
| Close() error | |
| } | |
| // DataDecoder decodes raw bytes (e.g., TIS-620/Windows-874) | |
| type DataDecoder interface { | |
| Decode(input []byte) ([]byte, error) | |
| } | |
| // FileWriter writes data to storage | |
| type FileWriter interface { | |
| Write(ctx context.Context, path string, data []byte) error | |
| } | |
| // Logger provides structured logging | |
| type Logger interface { | |
| Info(msg string, fields ...any) | |
| Error(msg string, err error, fields ...any) | |
| Debug(msg string, fields ...any) | |
| } | |
| // ============================================================================ | |
| // Logger Implementation | |
| // ============================================================================ | |
| type stdLogger struct { | |
| logger *log.Logger | |
| debug bool | |
| } | |
| func newStdLogger(debug bool) *stdLogger { | |
| return &stdLogger{ | |
| logger: log.New(os.Stdout, "[SmartCard] ", log.LstdFlags|log.Lmsgprefix), | |
| debug: debug, | |
| } | |
| } | |
| func (l *stdLogger) Info(msg string, fields ...any) { | |
| if len(fields) > 0 { | |
| l.logger.Printf("%s %v", msg, fields) | |
| } else { | |
| l.logger.Println(msg) | |
| } | |
| } | |
| func (l *stdLogger) Error(msg string, err error, fields ...any) { | |
| args := []any{msg, err} | |
| args = append(args, fields...) | |
| l.logger.Printf("%s: %v %v", args...) | |
| } | |
| func (l *stdLogger) Debug(msg string, fields ...any) { | |
| if l.debug { | |
| if len(fields) > 0 { | |
| l.logger.Printf("[DEBUG] %s %v", msg, fields) | |
| } else { | |
| l.logger.Println("[DEBUG]", msg) | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // Implementations - Reader Selector | |
| // ============================================================================ | |
| // ConsoleReaderSelector prompts the user to select a reader via stdin | |
| type ConsoleReaderSelector struct { | |
| logger Logger | |
| input io.Reader | |
| } | |
| func NewConsoleReaderSelector(logger Logger) *ConsoleReaderSelector { | |
| return &ConsoleReaderSelector{ | |
| logger: logger, | |
| input: os.Stdin, | |
| } | |
| } | |
| func (s *ConsoleReaderSelector) Select(ctx context.Context, readers []string) (int, error) { | |
| if len(readers) == 0 { | |
| return -1, ErrNoReadersAvailable | |
| } | |
| // Auto-select if only one reader | |
| if len(readers) == 1 { | |
| s.logger.Info("Auto-selecting reader", "name", readers[0]) | |
| return 0, nil | |
| } | |
| s.logger.Info("Available readers:") | |
| for i, r := range readers { | |
| fmt.Fprintf(os.Stdout, " %d) %s\n", i, r) | |
| } | |
| fmt.Fprint(os.Stdout, "Select reader [0]: ") | |
| select { | |
| case <-ctx.Done(): | |
| return 0, ctx.Err() | |
| default: | |
| } | |
| var selection string | |
| if _, err := fmt.Fscanln(s.input, &selection); err != nil { | |
| s.logger.Error("Failed to read selection", err) | |
| return 0, &CardError{Op: "select_reader", Err: err} | |
| } | |
| n, err := strconv.Atoi(selection) | |
| if err != nil { | |
| s.logger.Info("Invalid input, defaulting to 0", "input", selection) | |
| return 0, nil | |
| } | |
| if n < 0 || n >= len(readers) { | |
| return 0, &CardError{Op: "select_reader", Err: ErrInvalidReaderIndex} | |
| } | |
| s.logger.Info("Reader selected", "index", n, "name", readers[n]) | |
| return n, nil | |
| } | |
| // ============================================================================ | |
| // Implementations - Card Connector | |
| // ============================================================================ | |
| // ScardConnector wraps scard.Context.Connect | |
| type ScardConnector struct { | |
| logger Logger | |
| } | |
| func NewScardConnector(logger Logger) *ScardConnector { | |
| return &ScardConnector{logger: logger} | |
| } | |
| func (c *ScardConnector) Connect(ctx context.Context, scardCtx *scard.Context, reader string) (*scard.Card, error) { | |
| result := make(chan *scard.Card, 1) | |
| errChan := make(chan error, 1) | |
| go func() { | |
| card, err := scardCtx.Connect(reader, scard.ShareShared, scard.ProtocolAny) | |
| if err != nil { | |
| errChan <- err | |
| return | |
| } | |
| result <- card | |
| }() | |
| select { | |
| case <-ctx.Done(): | |
| return nil, &CardError{Op: "connect", Err: ctx.Err()} | |
| case err := <-errChan: | |
| return nil, &CardError{Op: "connect", Err: err} | |
| case card := <-result: | |
| c.logger.Info("Connected to card", "reader", reader) | |
| return card, nil | |
| } | |
| } | |
| // ============================================================================ | |
| // Implementations - Smart Card Service | |
| // ============================================================================ | |
| // smartCardService implements SmartCardService for scard.Card | |
| type smartCardService struct { | |
| card *scard.Card | |
| reqPrefix []byte | |
| logger Logger | |
| closed bool | |
| } | |
| func NewSmartCardService(card *scard.Card, reqPrefix []byte, logger Logger) SmartCardService { | |
| copyReq := make([]byte, len(reqPrefix)) | |
| copy(copyReq, reqPrefix) | |
| return &smartCardService{ | |
| card: card, | |
| reqPrefix: copyReq, | |
| logger: logger, | |
| } | |
| } | |
| func (s *smartCardService) Status(ctx context.Context) (scard.Status, error) { | |
| result := make(chan scard.Status, 1) | |
| errChan := make(chan error, 1) | |
| go func() { | |
| st, err := s.card.Status() | |
| if err != nil { | |
| errChan <- err | |
| return | |
| } | |
| result <- st | |
| }() | |
| select { | |
| case <-ctx.Done(): | |
| return scard.Status{}, &CardError{Op: "status", Err: ctx.Err()} | |
| case err := <-errChan: | |
| return scard.Status{}, &CardError{Op: "status", Err: err} | |
| case st := <-result: | |
| s.logger.Debug("Card status", "reader", st.Reader, "state", st.State, "atr", st.Atr) | |
| return st, nil | |
| } | |
| } | |
| func (s *smartCardService) ATR(ctx context.Context) ([]byte, error) { | |
| atr, err := s.card.GetAttrib(scard.AttrAtrString) | |
| if err != nil { | |
| return nil, &CardError{Op: "get_atr", Err: err} | |
| } | |
| s.logger.Debug("ATR retrieved", "atr", atr) | |
| return atr, nil | |
| } | |
| func (s *smartCardService) AdjustRequest(atr []byte) { | |
| if len(atr) > 1 && atr[0] == 0x3B && atr[1] == 0x67 { | |
| if len(s.reqPrefix) > 3 { | |
| s.reqPrefix[3] = 0x01 | |
| s.logger.Debug("Request prefix adjusted for Thai card") | |
| } | |
| } | |
| } | |
| func (s *smartCardService) Transmit(ctx context.Context, cmd []byte) ([]byte, error) { | |
| result := make(chan []byte, 1) | |
| errChan := make(chan error, 1) | |
| go func() { | |
| resp, err := s.card.Transmit(cmd) | |
| if err != nil { | |
| errChan <- err | |
| return | |
| } | |
| result <- resp | |
| }() | |
| select { | |
| case <-ctx.Done(): | |
| return nil, &CardError{Op: "transmit", Err: ctx.Err()} | |
| case err := <-errChan: | |
| return nil, &CardError{Op: "transmit", Err: err} | |
| case resp := <-result: | |
| s.logger.Debug("Transmit complete", "cmd", cmd, "response_len", len(resp)) | |
| return resp, nil | |
| } | |
| } | |
| func (s *smartCardService) GetData(ctx context.Context, cmd []byte) ([]byte, error) { | |
| if _, err := s.Transmit(ctx, cmd); err != nil { | |
| return nil, fmt.Errorf("initial transmit failed: %w", err) | |
| } | |
| if len(cmd) == 0 { | |
| return nil, &CardError{Op: "get_data", Err: ErrInsufficientData} | |
| } | |
| tail := cmd[len(cmd)-1] | |
| resp, err := s.Transmit(ctx, append(s.reqPrefix, tail)) | |
| if err != nil { | |
| return nil, fmt.Errorf("response transmit failed: %w", err) | |
| } | |
| if len(resp) < 2 { | |
| return nil, &CardError{Op: "get_data", Err: ErrInvalidCardResponse} | |
| } | |
| return resp[:len(resp)-2], nil | |
| } | |
| func (s *smartCardService) Close() error { | |
| if s.closed { | |
| return nil | |
| } | |
| s.closed = true | |
| return s.card.Disconnect(scard.ResetCard) | |
| } | |
| // ============================================================================ | |
| // Implementations - Data Decoder | |
| // ============================================================================ | |
| // CharmapDecoder wraps charmap.Decoder for DataDecoder interface | |
| type CharmapDecoder struct { | |
| decoder *charmap.Charmap | |
| } | |
| func NewCharmapDecoder(ch *charmap.Charmap) *CharmapDecoder { | |
| return &CharmapDecoder{decoder: ch} | |
| } | |
| func (d *CharmapDecoder) Decode(input []byte) ([]byte, error) { | |
| if len(input) == 0 { | |
| return []byte{}, nil | |
| } | |
| return d.decoder.NewDecoder().Bytes(input), nil | |
| } | |
| // ============================================================================ | |
| // Implementations - File Writer | |
| // ============================================================================ | |
| // OSFileWriter implements FileWriter using os package | |
| type OSFileWriter struct { | |
| logger Logger | |
| } | |
| func NewOSFileWriter(logger Logger) *OSFileWriter { | |
| return &OSFileWriter{logger: logger} | |
| } | |
| func (w *OSFileWriter) Write(ctx context.Context, path string, data []byte) error { | |
| result := make(chan error, 1) | |
| go func() { | |
| result <- os.WriteFile(path, data, 0644) | |
| }() | |
| select { | |
| case <-ctx.Done(): | |
| return &CardError{Op: "write_file", Err: ctx.Err()} | |
| case err := <-result: | |
| if err != nil { | |
| return &CardError{Op: "write_file", Err: err} | |
| } | |
| w.logger.Info("File written", "path", path, "size", len(data)) | |
| return nil | |
| } | |
| } | |
| // ============================================================================ | |
| // Command Provider | |
| // ============================================================================ | |
| // InfoCommand represents a named APDU command for reading card data | |
| type InfoCommand struct { | |
| Name string | |
| Cmd []byte | |
| } | |
| // CommandProvider holds all APDU commands for Thai national ID card | |
| type CommandProvider struct { | |
| SelectThai []byte | |
| Infos []InfoCommand | |
| Photos [][]byte | |
| reqPrefix []byte | |
| reqPrefixLen int | |
| } | |
| // NewCommandProvider creates a new CommandProvider with Thai ID card commands | |
| func NewCommandProvider() *CommandProvider { | |
| reqPrefix := []byte{0x00, 0xC0, 0x00, 0x00} | |
| // Pre-allocate photo commands for efficiency | |
| photos := make([][]byte, 15) | |
| for i := range photos { | |
| photos[i] = []byte{0x80, 0xB0, byte(i + 1), byte(0x7B - i), 0x02, 0x00, 0xFF} | |
| } | |
| return &CommandProvider{ | |
| SelectThai: []byte{0x00, 0xA4, 0x04, 0x00, 0x08, 0xA0, 0x00, 0x00, 0x00, 0x54, 0x48, 0x00, 0x01}, | |
| Infos: []InfoCommand{ | |
| {"CID", []byte{0x80, 0xB0, 0x00, 0x04, 0x02, 0x00, 0x0D}}, | |
| {"ThaiFullname", []byte{0x80, 0xB0, 0x00, 0x11, 0x02, 0x00, 0x64}}, | |
| {"EnglishFullname", []byte{0x80, 0xB0, 0x00, 0x75, 0x02, 0x00, 0x64}}, | |
| {"Birthdate", []byte{0x80, 0xB0, 0x00, 0xD9, 0x02, 0x00, 0x08}}, | |
| {"Gender", []byte{0x80, 0xB0, 0x00, 0xE1, 0x02, 0x00, 0x01}}, | |
| {"Issuer", []byte{0x80, 0xB0, 0x00, 0xF6, 0x02, 0x00, 0x64}}, | |
| {"IssueDate", []byte{0x80, 0xB0, 0x01, 0x67, 0x02, 0x00, 0x08}}, | |
| {"ExpireDate", []byte{0x80, 0xB0, 0x01, 0x6F, 0x02, 0x00, 0x08}}, | |
| {"Address", []byte{0x80, 0xB0, 0x15, 0x79, 0x02, 0x00, 0x64}}, | |
| }, | |
| Photos: photos, | |
| reqPrefix: reqPrefix, | |
| reqPrefixLen: len(reqPrefix), | |
| } | |
| } | |
| // ============================================================================ | |
| // Card Processor | |
| // ============================================================================ | |
| // ProcessResult contains all data read from the card | |
| type ProcessResult struct { | |
| Info map[string]string | |
| Photo []byte | |
| } | |
| // CardProcessor orchestrates the card reading workflow | |
| type CardProcessor struct { | |
| svc SmartCardService | |
| cmds *CommandProvider | |
| decoder DataDecoder | |
| writer FileWriter | |
| logger Logger | |
| photoSize int // Pre-calculated total photo size for optimization | |
| } | |
| // NewCardProcessor creates a new CardProcessor with all dependencies | |
| func NewCardProcessor( | |
| svc SmartCardService, | |
| cmds *CommandProvider, | |
| decoder DataDecoder, | |
| writer FileWriter, | |
| logger Logger, | |
| ) *CardProcessor { | |
| // Pre-calculate estimated photo size (15 chunks * max 255 bytes each) | |
| // This is a conservative estimate for pre-allocation | |
| return &CardProcessor{ | |
| svc: svc, | |
| cmds: cmds, | |
| decoder: decoder, | |
| writer: writer, | |
| logger: logger, | |
| photoSize: 15 * 255, // Conservative estimate | |
| } | |
| } | |
| // Process reads all data from the smart card | |
| func (p *CardProcessor) Process(ctx context.Context) (*ProcessResult, error) { | |
| result := &ProcessResult{ | |
| Info: make(map[string]string), | |
| } | |
| // Check card status | |
| st, err := p.svc.Status(ctx) | |
| if err != nil { | |
| return nil, fmt.Errorf("get status failed: %w", err) | |
| } | |
| p.logger.Info("Card status", "reader", st.Reader, "state", fmt.Sprintf("%x", st.State), "atr", fmt.Sprintf("% X", st.Atr)) | |
| // Get ATR and adjust request | |
| atr, err := p.svc.ATR(ctx) | |
| if err != nil { | |
| return nil, fmt.Errorf("get ATR failed: %w", err) | |
| } | |
| p.svc.AdjustRequest(atr) | |
| // Select Thai application | |
| if _, err := p.svc.Transmit(ctx, p.cmds.SelectThai); err != nil { | |
| return nil, fmt.Errorf("select Thai app failed: %w", err) | |
| } | |
| p.logger.Info("Thai application selected") | |
| // Read personal info | |
| for _, ic := range p.cmds.Infos { | |
| raw, err := p.svc.GetData(ctx, ic.Cmd) | |
| if err != nil { | |
| return nil, fmt.Errorf("get %s failed: %w", ic.Name, err) | |
| } | |
| decoded, err := p.decoder.Decode(raw) | |
| if err != nil { | |
| return nil, fmt.Errorf("decode %s failed: %w", ic.Name, err) | |
| } | |
| result.Info[ic.Name] = string(bytes.TrimSpace(decoded)) | |
| p.logger.Info("Info read", "field", ic.Name, "value", result.Info[ic.Name]) | |
| } | |
| // Read photo chunks with pre-allocation | |
| img := make([]byte, 0, p.photoSize) | |
| for i, pc := range p.cmds.Photos { | |
| chunk, err := p.svc.GetData(ctx, pc) | |
| if err != nil { | |
| return nil, fmt.Errorf("get photo chunk %d failed: %w", i, err) | |
| } | |
| img = append(img, chunk...) | |
| p.logger.Debug("Photo chunk read", "chunk", i, "size", len(chunk)) | |
| } | |
| result.Photo = img | |
| p.logger.Info("Photo read complete", "total_size", len(img)) | |
| return result, nil | |
| } | |
| // WritePhoto saves the photo to disk | |
| func (p *CardProcessor) WritePhoto(ctx context.Context, photo []byte, outputPath string) error { | |
| if len(photo) == 0 { | |
| return fmt.Errorf("no photo data to write") | |
| } | |
| return p.writer.Write(ctx, outputPath, photo) | |
| } | |
| // ============================================================================ | |
| // Main | |
| // ============================================================================ | |
| type Config struct { | |
| ReaderTimeout time.Duration | |
| OperationTimeout time.Duration | |
| Debug bool | |
| OutputPhotoPath string | |
| } | |
| func DefaultConfig() *Config { | |
| return &Config{ | |
| ReaderTimeout: 30 * time.Second, | |
| OperationTimeout: 10 * time.Second, | |
| Debug: false, | |
| OutputPhotoPath: "output.jpg", | |
| } | |
| } | |
| func main() { | |
| cfg := DefaultConfig() | |
| // Optionally enable debug logging: cfg.Debug = true | |
| // Optionally customize: cfg.OutputPhotoPath = "photo.jpg" | |
| logger := newStdLogger(cfg.Debug) | |
| ctx := context.Background() | |
| // Establish smart card context | |
| scardCtx, err := scard.EstablishContext() | |
| if err != nil { | |
| logger.Error("Failed to establish context", err) | |
| os.Exit(1) | |
| } | |
| defer scardCtx.Release() | |
| // List readers | |
| readers, err := scardCtx.ListReaders() | |
| if err != nil { | |
| logger.Error("Failed to list readers", err) | |
| os.Exit(1) | |
| } | |
| logger.Info("Readers found", "count", len(readers)) | |
| // Select reader with timeout | |
| readerCtx, cancel := context.WithTimeout(ctx, cfg.ReaderTimeout) | |
| defer cancel() | |
| selector := NewConsoleReaderSelector(logger) | |
| idx, err := selector.Select(readerCtx, readers) | |
| if err != nil { | |
| logger.Error("Failed to select reader", err) | |
| os.Exit(1) | |
| } | |
| // Connect to card | |
| connectCtx, cancel := context.WithTimeout(ctx, cfg.OperationTimeout) | |
| defer cancel() | |
| connector := NewScardConnector(logger) | |
| card, err := connector.Connect(connectCtx, scardCtx, readers[idx]) | |
| if err != nil { | |
| logger.Error("Failed to connect to card", err) | |
| os.Exit(1) | |
| } | |
| defer func() { | |
| if err := card.Disconnect(scard.ResetCard); err != nil { | |
| logger.Error("Failed to disconnect card", err) | |
| } | |
| }() | |
| // Initialize components | |
| cmds := NewCommandProvider() | |
| decoder := NewCharmapDecoder(charmap.Windows874) | |
| writer := NewOSFileWriter(logger) | |
| svc := NewSmartCardService(card, cmds.reqPrefix, logger) | |
| processor := NewCardProcessor(svc, cmds, decoder, writer, logger) | |
| // Process card with timeout | |
| procCtx, cancel := context.WithTimeout(ctx, cfg.OperationTimeout*6) // Longer timeout for full process | |
| defer cancel() | |
| result, err := processor.Process(procCtx) | |
| if err != nil { | |
| logger.Error("Processing error", err) | |
| os.Exit(1) | |
| } | |
| // Write photo | |
| if err := processor.WritePhoto(procCtx, result.Photo, cfg.OutputPhotoPath); err != nil { | |
| logger.Error("Failed to write photo", err) | |
| os.Exit(1) | |
| } | |
| // Print results | |
| logger.Info("=== Card Data ===") | |
| fmt.Println() | |
| for name, value := range result.Info { | |
| fmt.Printf("%-18s : %s\n", name, value) | |
| } | |
| fmt.Println() | |
| logger.Info("Photo saved", "path", cfg.OutputPhotoPath) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment