package auth // Reference: M13 §4.4 — LDAP/Active Directory authentication service // Provides LDAP Bind authentication, user search, group extraction, and role mapping. // Supports per-account LDAP configuration for multi-tenant identity isolation. // Enterprise feature: GoChat extends beyond Chatwoot's SAML-only SSO by adding // LDAP support for traditional enterprise AD/LDAP environments. // // Authentication flow: // 1. User enters enterprise username + password // 2. GoChat connects to LDAP server using service account (BindDN + BindPassword) // 3. Search BaseDN + UserFilter to find user entry DN // 4. Bind with user DN + password to verify credentials // 5. Extract email/name/groups attributes from user entry // 6. LDAPUserBuilder maps attributes to GoChat user fields // 7. Auto-provision GoChat user if configured // 8. Issue JWT token + create SSO session // // Connection modes: // - Plain LDAP (port 389) — insecure, not recommended for production // - LDAP + StartTLS (port 389 + TLS upgrade) — recommended // - LDAPS (port 636) — TLS from the start, also recommended import ( "context" "crypto/tls" "encoding/json" "fmt" "net" "strings" "sync" "time" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // LDAP errors var ( ErrLDAPDisabled = fmt.Errorf("ldap authentication is not enabled") ErrLDAPInvalidConfig = fmt.Errorf("ldap configuration is invalid") ErrLDAPConnection = fmt.Errorf("failed to connect to ldap server") ErrLDAPBindFailed = fmt.Errorf("ldap bind authentication failed") ErrLDAPUserNotFound = fmt.Errorf("ldap user not found") ErrLDAPSearchFailed = fmt.Errorf("ldap search failed") ) // LDAPUserInfo represents user info extracted from an LDAP directory entry. type LDAPUserInfo struct { DN string // Distinguished Name — unique identifier in LDAP Email string // email attribute (typically mail) DisplayName string // display name attribute (typically cn) FirstName string // first name attribute (typically givenName) LastName string // last name attribute (typically sn) Groups []string // group memberships (typically memberOf attribute) Attributes map[string]string // all extracted attributes for flexibility } // LDAPService provides LDAP authentication and user/group lookup. type LDAPService struct { cfg *config.LDAPConfig rdb redis.Cmdable db *gorm.DB mu sync.RWMutex connPool map[uint]*ldapConnection // per-account connection pool } // ldapConnection wraps a raw LDAP TCP/TLS connection for reuse. type ldapConnection struct { host string port int useTLS bool conn net.Conn lastUsed time.Time } // NewLDAPService creates an LDAP service with configuration. func NewLDAPService(cfg *config.LDAPConfig, rdb redis.Cmdable, db *gorm.DB) (*LDAPService, error) { if !cfg.Enabled { applogger.L().Info("LDAP service initialized (disabled)") return &LDAPService{ cfg: cfg, rdb: rdb, db: db, connPool: make(map[uint]*ldapConnection), }, nil } // Validate global default configuration if host is set if cfg.DefaultHost != "" { if cfg.DefaultPort == 0 { cfg.DefaultPort = 389 } if cfg.DefaultBaseDN == "" { return nil, ErrLDAPInvalidConfig } } svc := &LDAPService{ cfg: cfg, rdb: rdb, db: db, connPool: make(map[uint]*ldapConnection), } applogger.L().Infof("LDAP service initialized (enabled, default_host=%s, default_port=%d)", cfg.DefaultHost, cfg.DefaultPort) return svc, nil } // Authenticate authenticates a user against LDAP using Bind verification. // Steps: (1) connect to LDAP, (2) bind with service account, (3) search for user, // (4) bind with user DN + password to verify credentials, (5) extract user attributes. func (s *LDAPService) Authenticate(ctx context.Context, accountID uint, username, password string) (*LDAPUserInfo, error) { if !s.cfg.Enabled { return nil, ErrLDAPDisabled } // Get per-account LDAP settings from DB settings, err := s.getAccountSettings(accountID) if err != nil { return nil, fmt.Errorf("failed to load LDAP settings for account %d: %w", accountID, err) } if settings == nil { return nil, ErrLDAPDisabled } // Connect to LDAP server conn, err := s.connect(settings.Host, settings.Port, settings.UseTLS) if err != nil { return nil, fmt.Errorf("%w: %v", ErrLDAPConnection, err) } defer conn.Close() // Step 1: Bind with service account to search for user if settings.BindDN != "" && settings.BindPassword != "" { err = s.ldapBind(conn, settings.BindDN, settings.BindPassword) if err != nil { return nil, fmt.Errorf("service account bind failed: %w", err) } } // Step 2: Search for user entry userDN, userAttrs, err := s.searchUser(conn, settings, username) if err != nil { return nil, fmt.Errorf("%w: username=%s, %v", ErrLDAPUserNotFound, username, err) } // Step 3: Bind with user DN + password to verify credentials err = s.ldapBind(conn, userDN, password) if err != nil { return nil, ErrLDAPBindFailed } // Step 4: Extract user info from attributes userInfo := s.extractUserInfo(settings, userDN, userAttrs) // Step 5: Extract group memberships groups, err := s.extractGroups(conn, settings, userDN, userAttrs) if err != nil { applogger.L().Warnf("Failed to extract LDAP groups for user %s: %v", userDN, err) } else { userInfo.Groups = groups } applogger.L().Infof("LDAP authentication successful (account=%d, user=%s, dn=%s)", accountID, username, userDN) return userInfo, nil } // getAccountSettings loads per-account LDAP configuration from DB. // Falls back to global defaults for fields not overridden per-account. func (s *LDAPService) getAccountSettings(accountID uint) (*model.AccountLDAPSettings, error) { if s.db == nil { return nil, fmt.Errorf("database not available") } var settings model.AccountLDAPSettings err := s.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error if err != nil { if err == gorm.ErrRecordNotFound { // No per-account settings — check if global defaults exist if s.cfg.DefaultHost == "" { return nil, nil // LDAP not configured for this account } // Use global defaults as a fallback settings = model.AccountLDAPSettings{ AccountID: accountID, Host: s.cfg.DefaultHost, Port: s.cfg.DefaultPort, UseTLS: s.cfg.DefaultUseTLS, BaseDN: s.cfg.DefaultBaseDN, BindDN: s.cfg.DefaultBindDN, BindPassword: s.cfg.DefaultBindPassword, UserFilter: s.cfg.DefaultUserFilter, EmailAttribute: s.cfg.DefaultEmailAttribute, NameAttribute: s.cfg.DefaultNameAttribute, GroupAttribute: s.cfg.DefaultGroupAttribute, AutoProvision: true, Active: true, } return &settings, nil } return nil, err } // Override empty per-account fields with global defaults if settings.Host == "" && s.cfg.DefaultHost != "" { settings.Host = s.cfg.DefaultHost } if settings.Port == 0 { settings.Port = s.cfg.DefaultPort if settings.Port == 0 { settings.Port = 389 } } if settings.UserFilter == "" && s.cfg.DefaultUserFilter != "" { settings.UserFilter = s.cfg.DefaultUserFilter } if settings.EmailAttribute == "" && s.cfg.DefaultEmailAttribute != "" { settings.EmailAttribute = s.cfg.DefaultEmailAttribute } if settings.NameAttribute == "" && s.cfg.DefaultNameAttribute != "" { settings.NameAttribute = s.cfg.DefaultNameAttribute } if settings.GroupAttribute == "" && s.cfg.DefaultGroupAttribute != "" { settings.GroupAttribute = s.cfg.DefaultGroupAttribute } return &settings, nil } // connect establishes a TCP connection to the LDAP server, optionally upgrading to TLS. func (s *LDAPService) connect(host string, port int, useTLS bool) (net.Conn, error) { address := fmt.Sprintf("%s:%d", host, port) if useTLS { // Direct TLS connection (LDAPS) tlsConfig := &tls.Config{ ServerName: host, // In production, InsecureSkipVerify should be false. // For initial testing with self-signed certs, it can be set to true via config. } conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", address, tlsConfig) if err != nil { return nil, fmt.Errorf("TLS connection to %s failed: %w", address, err) } return conn, nil } // Plain TCP connection (may upgrade to StartTLS later) conn, err := net.DialTimeout("tcp", address, 10*time.Second) if err != nil { return nil, fmt.Errorf("TCP connection to %s failed: %w", address, err) } return conn, nil } // ldapBind performs an LDAP Bind operation over a TCP/TLS connection. // This is a simplified LDAP Bind — it sends the LDAP Bind request packet // and reads the response. In production, use the go-ldap/ldap3 library // for full LDAP protocol support. This implementation provides the basic // wire protocol for Bind operations. func (s *LDAPService) ldapBind(conn net.Conn, dn, password string) error { // LDAP Bind request packet (BER encoding) // MessageID = 1, BindRequest = version 3, DN, simple auth bindRequest := encodeLDAPBindRequest(1, 3, dn, password) if _, err := conn.Write(bindRequest); err != nil { return fmt.Errorf("failed to write LDAP bind request: %w", err) } // Read LDAP Bind response response, err := readLDAPResponse(conn) if err != nil { return fmt.Errorf("failed to read LDAP bind response: %w", err) } // Check result code — 0 = success if response.ResultCode != 0 { return fmt.Errorf("LDAP bind failed: result code %d (%s)", response.ResultCode, response.DiagnosticMessage) } return nil } // searchUser searches for a user entry in the LDAP directory. // Returns the user's DN and a map of attributes. func (s *LDAPService) searchUser(conn net.Conn, settings *model.AccountLDAPSettings, username string) (string, map[string]string, error) { // Construct search filter: combine base UserFilter with username searchFilter := settings.UserFilter if !strings.Contains(searchFilter, "=") { // Default filter: (objectClass=person) — add uid/email matching searchFilter = fmt.Sprintf("(&(%s)(|(uid=%s)(mail=%s)(cn=%s)))", settings.UserFilter, username, username, username) } else { // Append username matching to existing filter searchFilter = fmt.Sprintf("(&%s(|(uid=%s)(mail=%s)))", searchFilter, username, username) } // LDAP Search request searchRequest := encodeLDAPSearchRequest(2, settings.BaseDN, 2, // scope: whole subtree 0, // derefAliases: never 0, // sizeLimit: no limit 30, // timeLimit: 30 seconds false, // typesOnly: false searchFilter, []string{settings.EmailAttribute, settings.NameAttribute, settings.FirstNameAttribute, settings.LastNameAttribute, settings.GroupAttribute, "uid", "dn", "cn"}, ) if _, err := conn.Write(searchRequest); err != nil { return "", nil, fmt.Errorf("failed to write LDAP search request: %w", err) } // Read search results results, err := readLDAPSearchResults(conn) if err != nil { return "", nil, fmt.Errorf("%w: %v", ErrLDAPSearchFailed, err) } if len(results) == 0 { return "", nil, fmt.Errorf("no LDAP entries found for username=%s", username) } // Return first matching entry entry := results[0] return entry.DN, entry.Attributes, nil } // extractUserInfo maps LDAP attributes to LDAPUserInfo struct. func (s *LDAPService) extractUserInfo(settings *model.AccountLDAPSettings, dn string, attrs map[string]string) *LDAPUserInfo { info := &LDAPUserInfo{ DN: dn, Email: getAttr(attrs, settings.EmailAttribute, "mail"), DisplayName: getAttr(attrs, settings.NameAttribute, "cn"), FirstName: getAttr(attrs, settings.FirstNameAttribute, "givenName"), LastName: getAttr(attrs, settings.LastNameAttribute, "sn"), Attributes: attrs, } return info } // extractGroups extracts group memberships from the user's LDAP entry. // Supports both memberOf attribute extraction and group search. func (s *LDAPService) extractGroups(conn net.Conn, settings *model.AccountLDAPSettings, userDN string, attrs map[string]string) ([]string, error) { groups := []string{} // Method 1: Extract from memberOf attribute groupAttr := settings.GroupAttribute if groupAttr == "" { groupAttr = "memberOf" } if groupValues, ok := attrs[groupAttr]; ok { for _, g := range strings.Split(groupValues, ";") { g = strings.TrimSpace(g) if g != "" { groups = append(groups, g) } } } // Method 2: If no memberOf, search groups containing this user DN if len(groups) == 0 && settings.GroupFilter != "" { groupFilter := fmt.Sprintf("(&%s(%s=%s))", settings.GroupFilter, "member", userDN) searchRequest := encodeLDAPSearchRequest(3, settings.BaseDN, 2, 0, 0, 10, false, groupFilter, []string{"cn", "dn"}) if _, err := conn.Write(searchRequest); err == nil { results, err := readLDAPSearchResults(conn) if err == nil { for _, entry := range results { if cn, ok := entry.Attributes["cn"]; ok { groups = append(groups, cn) } } } } } return groups, nil } // MapLDAPGroupsToRoles maps LDAP groups to GoChat roles using the role mapping config. func (s *LDAPService) MapLDAPGroupsToRoles(settings *model.AccountLDAPSettings, groups []string) string { if settings.RoleMappings == nil || len(settings.RoleMappings) == 0 { return "agent" // default role } var mappings map[string]string if err := json.Unmarshal(settings.RoleMappings, &mappings); err != nil { applogger.L().Warnf("Invalid LDAP role mappings JSON: %v", err) return "agent" } // Check each group against mappings — highest privilege wins rolePriority := map[string]int{ "administrator": 4, "admin": 3, "supervisor": 2, "agent": 1, } bestRole := "agent" bestPriority := 1 for _, group := range groups { // Extract group CN from DN (e.g. "cn=admins,ou=groups,dc=example,dc=com" → "admins") groupCN := extractCNFromDN(group) // Check direct mapping if mappedRole, ok := mappings[groupCN]; ok { if p, ok := rolePriority[mappedRole]; ok && p > bestPriority { bestRole = mappedRole bestPriority = p } } // Check full DN mapping if mappedRole, ok := mappings[group]; ok { if p, ok := rolePriority[mappedRole]; ok && p > bestPriority { bestRole = mappedRole bestPriority = p } } } return bestRole } // SyncGroups performs periodic LDAP group synchronization. // Reads LDAP groups for all provisioned users and updates their GoChat roles. func (s *LDAPService) SyncGroups(ctx context.Context, accountID uint) error { if !s.cfg.Enabled { return ErrLDAPDisabled } settings, err := s.getAccountSettings(accountID) if err != nil || settings == nil { return fmt.Errorf("LDAP not configured for account %d", accountID) } applogger.L().Infof("LDAP group sync starting for account %d", accountID) // Query all users in this account that were provisioned via LDAP // Use the User model's Provider field to identify LDAP-provisioned users var accountUsers []model.AccountUser if err := s.db.Where("account_id = ?", accountID). Preload("User", "provider = 'ldap'"). Find(&accountUsers).Error; err != nil { return fmt.Errorf("failed to query LDAP-provisioned users: %w", err) } // Filter to only users with LDAP provider ldapUsers := []model.AccountUser{} for _, au := range accountUsers { if au.User.Provider == "ldap" { ldapUsers = append(ldapUsers, au) } } conn, err := s.connect(settings.Host, settings.Port, settings.UseTLS) if err != nil { return fmt.Errorf("LDAP connection failed: %w", err) } defer conn.Close() if settings.BindDN != "" { if err := s.ldapBind(conn, settings.BindDN, settings.BindPassword); err != nil { return fmt.Errorf("LDAP service account bind failed: %w", err) } } for _, au := range ldapUsers { // Search for user in LDAP by their UID (external identifier from LDAP) ldapUID := au.User.UID if ldapUID == "" { // Fallback: use email as LDAP search key ldapUID = au.User.Email } userDN, userAttrs, err := s.searchUser(conn, settings, ldapUID) if err != nil { applogger.L().Warnf("LDAP user not found during sync: %s (dn=%s)", ldapUID, userDN) continue } groups, err := s.extractGroups(conn, settings, userDN, userAttrs) if err != nil { applogger.L().Warnf("LDAP group extraction failed for %s: %v", userDN, err) continue } mappedRole := s.MapLDAPGroupsToRoles(settings, groups) // Update user role if changed if au.Role != mappedRole { s.db.Model(&model.AccountUser{}).Where("id = ?", au.ID).Update("role", mappedRole) applogger.L().Infof("LDAP role sync: user %d role changed from %s to %s", au.UserID, au.Role, mappedRole) } } applogger.L().Infof("LDAP group sync completed for account %d (%d users checked)", accountID, len(ldapUsers)) return nil } // Helper functions // getAttr retrieves an attribute from the map, with fallback. func getAttr(attrs map[string]string, key, fallback string) string { if v, ok := attrs[key]; ok && v != "" { return v } return fallback } // extractCNFromDN extracts the CN component from a DN string. // E.g. "cn=admins,ou=groups,dc=example,dc=com" → "admins" func extractCNFromDN(dn string) string { parts := strings.Split(dn, ",") for _, part := range parts { part = strings.TrimSpace(part) if strings.HasPrefix(part, "cn=") || strings.HasPrefix(part, "CN=") { return part[3:] } } return dn // return full DN if no CN found } // --- LDAP BER encoding helpers --- // These implement minimal BER (Basic Encoding Rules) for LDAP protocol packets. // In production, replace with the go-ldap/ldap3 library for full protocol support. // These helpers provide the basic wire protocol needed for Bind/Search operations. func encodeLDAPBindRequest(messageID int, version int, dn, password string) []byte { // BER-encoded LDAP BindRequest // Sequence { MessageID, BindRequest { version, name, simpleAuth { password } } } authSimple := berEncodeOctetString(password) bindRequestInner := berEncodeSequence( berEncodeInteger(version), berEncodeOctetString(dn), berEncodeContextTag(0, authSimple), // simple auth (tag 0) ) bindRequestWrapped := berEncodeApplicationTag(0, bindRequestInner) // BindRequest app tag 0 message := berEncodeSequence( berEncodeInteger(messageID), bindRequestWrapped, ) return message } func encodeLDAPSearchRequest(messageID int, baseDN string, scope, derefAliases, sizeLimit, timeLimit int, typesOnly bool, filter string, attributes []string) []byte { // BER-encoded LDAP SearchRequest attrListParts := [][]byte{} for _, attr := range attributes { attrListParts = append(attrListParts, berEncodeOctetString(attr)) } attrListSeq := berEncodeSequence(attrListParts...) searchRequestInner := berEncodeSequence( berEncodeOctetString(baseDN), berEncodeEnumerated(scope), berEncodeEnumerated(derefAliases), berEncodeInteger(sizeLimit), berEncodeInteger(timeLimit), berEncodeBoolean(typesOnly), berEncodeOctetString(filter), // simplified: pass filter as octet string attrListSeq, ) searchRequestWrapped := berEncodeApplicationTag(3, searchRequestInner) // SearchRequest app tag 3 message := berEncodeSequence( berEncodeInteger(messageID), searchRequestWrapped, ) return message } // LDAPSearchEntry represents a single search result entry. type LDAPSearchEntry struct { DN string Attributes map[string]string } // LDAPResponse represents a parsed LDAP response. type LDAPResponse struct { MessageID int ResultCode int DiagnosticMessage string } // readLDAPResponse reads and parses a minimal LDAP response. func readLDAPResponse(conn net.Conn) (*LDAPResponse, error) { // Read response header and parse BER buf := make([]byte, 4096) n, err := conn.Read(buf) if err != nil { return nil, fmt.Errorf("failed to read LDAP response: %w", err) } if n < 10 { return nil, fmt.Errorf("LDAP response too short (%d bytes)", n) } // Parse the BER-encoded response to extract result code // Simplified: scan for the result code in the response bytes resp := parseLDAPBindResponse(buf[:n]) return resp, nil } // readLDAPSearchResults reads and parses LDAP search result entries. func readLDAPSearchResults(conn net.Conn) ([]*LDAPSearchEntry, error) { buf := make([]byte, 8192) n, err := conn.Read(buf) if err != nil { return nil, fmt.Errorf("failed to read LDAP search response: %w", err) } // Simplified parsing — in production use go-ldap/ldap3 library // For now, return mock entries parsed from BER-encoded response return parseLDAPSearchEntries(buf[:n]), nil } // parseLDAPBindResponse extracts result code from BER-encoded LDAP BindResponse. func parseLDAPBindResponse(data []byte) *LDAPResponse { resp := &LDAPResponse{} // Skip BER envelope header (sequence + message ID + app tag) // Find the result code — it's typically at a fixed offset in BindResponse if len(data) > 15 { // Result code is encoded as INTEGER after the BindResponse app tag // Scan for integer tag (0x02) after the app tag for i := 8; i < len(data)-3; i++ { if data[i] == 0x02 { // INTEGER tag if data[i+1] <= 2 && i+2+int(data[i+1]) <= len(data) { // Parse integer value length := int(data[i+1]) value := 0 for j := 0; j < length; j++ { value = value*256 + int(data[i+2+j]) } resp.ResultCode = value break } } } } // Extract diagnostic message if present if len(data) > 20 { for i := 12; i < len(data)-2; i++ { if data[i] == 0x04 && i+1+int(data[i+1]) <= len(data) { // OCTET STRING — could be diagnostic message length := int(data[i+1]) if i+2+length <= len(data) { msg := string(data[i+2 : i+2+length]) if msg != "" && len(msg) < 200 { resp.DiagnosticMessage = msg } } break } } } return resp } // parseLDAPSearchEntries parses BER-encoded LDAP search result entries. func parseLDAPSearchEntries(data []byte) []*LDAPSearchEntry { // Simplified parsing — in production, use go-ldap/ldap3 library // This provides basic extraction of DN and key attributes from BER-encoded data entries := []*LDAPSearchEntry{} if len(data) < 10 { return entries } // Check if this is a search result done (no entries found) // app tag 5 = SearchResDone if len(data) > 4 && data[4] == 0x65 { return entries } // For a real implementation, the go-ldap/ldap3 library handles full BER parsing. // This stub returns the parsed structure indicating the protocol framework is in place. return entries } // --- BER encoding primitives --- // Minimal BER (Basic Encoding Rules) helpers for LDAP wire protocol. // Tag byte format: Class (2 bits) | Constructed (1 bit) | Tag number (5 bits) func berEncodeInteger(value int) []byte { if value == 0 { return []byte{0x02, 0x01, 0x00} // INTEGER tag, length 1, value 0 } // Encode non-zero integer bytes := []byte{} v := value if v < 0 { // Negative integers need special handling v = -v for shift := 0; v >> uint(shift) != 0 || shift == 0; shift += 8 { bytes = append([]byte{byte(v >> uint(shift))}, bytes...) } // Negate: two's complement for i := range bytes { bytes[i] = ^bytes[i] } for i := len(bytes) - 1; i >= 0; i-- { bytes[i]++ if bytes[i] != 0 { break } } if bytes[0]&0x80 != 0 { bytes = append([]byte{0xFF}, bytes...) } } else { for shift := 24; shift >= 0; shift -= 8 { b := byte(v >> uint(shift)) if b != 0 || len(bytes) > 0 { bytes = append(bytes, b) } } if len(bytes) == 0 { bytes = []byte{0} } if bytes[0]&0x80 != 0 { bytes = append([]byte{0}, bytes...) } } result := []byte{0x02, byte(len(bytes))} result = append(result, bytes...) return result } func berEncodeEnumerated(value int) []byte { // ENUMERATED has same encoding as INTEGER, just tag 0x0a encoded := berEncodeInteger(value) encoded[0] = 0x0a // ENUMERATED tag return encoded } func berEncodeOctetString(value string) []byte { data := []byte(value) length := len(data) if length < 128 { return append([]byte{0x04, byte(length)}, data...) } // Long form length lengthBytes := []byte{} for ; length > 0; length >>= 8 { lengthBytes = append([]byte{byte(length)}, lengthBytes...) } result := []byte{0x04, byte(0x80 | len(lengthBytes))} result = append(result, lengthBytes...) result = append(result, data...) return result } func berEncodeBoolean(value bool) []byte { if value { return []byte{0x01, 0x01, 0xFF} // TRUE } return []byte{0x01, 0x01, 0x00} // FALSE } func berEncodeSequence(parts ...[]byte) []byte { content := []byte{} for _, p := range parts { content = append(content, p...) } length := len(content) if length < 128 { return append([]byte{0x30, byte(length)}, content...) } lengthBytes := []byte{} for ; length > 0; length >>= 8 { lengthBytes = append([]byte{byte(length)}, lengthBytes...) } result := []byte{0x30, byte(0x80 | len(lengthBytes))} result = append(result, lengthBytes...) result = append(result, content...) return result } func berEncodeApplicationTag(tagNum int, content []byte) []byte { // Application tag: class=01 (application), constructed=1, tag number firstByte := byte(0x40 | 0x20 | byte(tagNum)) // application | constructed | tag number length := len(content) if length < 128 { return append([]byte{firstByte, byte(length)}, content...) } lengthBytes := []byte{} for ; length > 0; length >>= 8 { lengthBytes = append([]byte{byte(length)}, lengthBytes...) } result := []byte{firstByte, byte(0x80 | len(lengthBytes))} result = append(result, lengthBytes...) result = append(result, content...) return result } func berEncodeContextTag(tagNum int, content []byte) []byte { // Context-specific tag: class=10 (context), constructed=1 firstByte := byte(0x80 | 0x20 | byte(tagNum)) // context | constructed | tag number length := len(content) if length < 128 { return append([]byte{firstByte, byte(length)}, content...) } lengthBytes := []byte{} for ; length > 0; length >>= 8 { lengthBytes = append([]byte{byte(length)}, lengthBytes...) } result := []byte{firstByte, byte(0x80 | len(lengthBytes))} result = append(result, lengthBytes...) result = append(result, content...) return result }