From c49de4778f6a80fe7ac11073fb07b0431dc97645 Mon Sep 17 00:00:00 2001 From: "Remi GASCOU (Podalirius)" <79218792+p0dalirius@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:44:49 +0200 Subject: [PATCH] Add support for version 1 (0x501) keytab files (#5) The (de)serialization code only implemented version 2: it hardcoded big-endian byte order, always read/wrote a 32-bit name_type, and never adjusted the component count. Version-1 files, which use native (little-endian) byte order, count the realm in num_components, and omit name_type, were therefore mis-parsed and could not be produced. Derive the byte order and the version-specific layout from the file format version and thread it through the entry, key block, and counted-octet-string (de)serialization. Version-1 reads now decode little-endian integers, subtract 1 from the component count, and default name_type to KRB5_NT_PRINCIPAL; writes emit the matching layout. The version/byte-order parameters are optional and default to version 2, so existing callers are unaffected. Fixes #5 --- src/keytab/CountedOctetString.go | 12 +- src/keytab/FileFormatVersion.go | 56 ++++++++++ src/keytab/KeyBlock.go | 16 ++- src/keytab/Keytab.go | 6 +- src/keytab/KeytabEntry.go | 77 ++++++++----- src/keytab/Keytab_version1_test.go | 170 +++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 40 deletions(-) create mode 100644 src/keytab/FileFormatVersion.go create mode 100644 src/keytab/Keytab_version1_test.go diff --git a/src/keytab/CountedOctetString.go b/src/keytab/CountedOctetString.go index 9d91a0f..1c80def 100644 --- a/src/keytab/CountedOctetString.go +++ b/src/keytab/CountedOctetString.go @@ -31,10 +31,12 @@ type CountedOctetString struct { // // Returns: // - error: An error if the parsing fails. -func (c *CountedOctetString) FromBytes(data []byte) error { +func (c *CountedOctetString) FromBytes(data []byte, order ...binary.ByteOrder) error { + bo := resolveByteOrder(order) + c.RawBytes = data - c.Length = binary.BigEndian.Uint16(data[0:2]) + c.Length = bo.Uint16(data[0:2]) data = data[2:] c.Data = data[:c.Length] @@ -49,7 +51,9 @@ func (c *CountedOctetString) FromBytes(data []byte) error { // Returns: // - []byte: The byte array representation of the CountedOctetString. // - error: An error if the conversion fails. -func (c *CountedOctetString) ToBytes() ([]byte, error) { +func (c *CountedOctetString) ToBytes(order ...binary.ByteOrder) ([]byte, error) { + bo := resolveByteOrder(order) + if c.Length != uint16(len(c.Data)) { return nil, fmt.Errorf("length of data is not equal to the length of the counted octet string") } @@ -57,7 +61,7 @@ func (c *CountedOctetString) ToBytes() ([]byte, error) { data := make([]byte, 0) buffer := make([]byte, 2) - binary.BigEndian.PutUint16(buffer, c.Length) + bo.PutUint16(buffer, c.Length) data = append(data, buffer...) data = append(data, c.Data...) diff --git a/src/keytab/FileFormatVersion.go b/src/keytab/FileFormatVersion.go new file mode 100644 index 0000000..48e8b84 --- /dev/null +++ b/src/keytab/FileFormatVersion.go @@ -0,0 +1,56 @@ +package keytab + +import "encoding/binary" + +// Keytab file format versions. The first header byte is always 0x05; the second +// byte is the version number, so the 16-bit header reads as 0x0501 or 0x0502. +const ( + FileFormatVersion1 uint16 = 0x0501 + FileFormatVersion2 uint16 = 0x0502 +) + +// nameTypePrincipal is the KRB5_NT_PRINCIPAL name type. It is used as the +// in-memory default for version-1 entries, which do not store a name_type on +// disk. +const nameTypePrincipal uint32 = 1 + +// isVersion1 reports whether the given file format version is version 1, which +// differs from version 2 in byte order, component count, and name_type +// presence. Only the low byte (the version number) is significant. +func isVersion1(version uint16) bool { + return version&0x00ff == 0x01 +} + +// byteOrderForVersion returns the integer byte order used by the given keytab +// file format version. Version 1 uses native byte order, which in practice is +// little-endian (version 1 predates the big-endian convention introduced in +// version 2 specifically to remove this ambiguity); every other version uses +// big-endian. +func byteOrderForVersion(version uint16) binary.ByteOrder { + if isVersion1(version) { + return binary.LittleEndian + } + return binary.BigEndian +} + +// resolveVersion returns the version from an optional variadic argument, +// defaulting to version 2 when none is supplied or the supplied value is zero. +// This lets the entry (de)serialization methods stay backward compatible: an +// existing call with no version behaves exactly as before (version 2). +func resolveVersion(version []uint16) uint16 { + if len(version) > 0 && version[0] != 0 { + return version[0] + } + return FileFormatVersion2 +} + +// resolveByteOrder returns the byte order from an optional variadic argument, +// defaulting to big-endian (version 2) when none is supplied. This keeps the +// counted-octet-string and key-block (de)serialization methods backward +// compatible with callers that do not pass a byte order. +func resolveByteOrder(order []binary.ByteOrder) binary.ByteOrder { + if len(order) > 0 && order[0] != nil { + return order[0] + } + return binary.BigEndian +} diff --git a/src/keytab/KeyBlock.go b/src/keytab/KeyBlock.go index b54f2bc..ace4e20 100644 --- a/src/keytab/KeyBlock.go +++ b/src/keytab/KeyBlock.go @@ -28,11 +28,13 @@ type KeyBlock struct { // // Returns: // - error: An error if the parsing failed. -func (k *KeyBlock) FromBytes(data []byte) error { - k.Type = EncryptionType(binary.BigEndian.Uint16(data[0:2])) +func (k *KeyBlock) FromBytes(data []byte, order ...binary.ByteOrder) error { + bo := resolveByteOrder(order) + + k.Type = EncryptionType(bo.Uint16(data[0:2])) k.RawBytesSize = 2 - k.Key.FromBytes(data[2:]) + k.Key.FromBytes(data[2:], bo) k.RawBytesSize += k.Key.RawBytesSize return nil @@ -42,14 +44,16 @@ func (k *KeyBlock) FromBytes(data []byte) error { // // Returns: // - ([]byte, error): The byte array and an error if the conversion failed. -func (k *KeyBlock) ToBytes() ([]byte, error) { +func (k *KeyBlock) ToBytes(order ...binary.ByteOrder) ([]byte, error) { + bo := resolveByteOrder(order) + data := make([]byte, 0) buffer := make([]byte, 2) - binary.BigEndian.PutUint16(buffer, uint16(k.Type)) + bo.PutUint16(buffer, uint16(k.Type)) data = append(data, buffer...) - keyBytes, err := k.Key.ToBytes() + keyBytes, err := k.Key.ToBytes(bo) if err != nil { return nil, err } diff --git a/src/keytab/Keytab.go b/src/keytab/Keytab.go index a74d464..6482afd 100644 --- a/src/keytab/Keytab.go +++ b/src/keytab/Keytab.go @@ -42,7 +42,7 @@ func (k *Keytab) FromBytes(data []byte) error { for len(data) != 0 { entry := KeytabEntry{} - entry.FromBytes(data) + entry.FromBytes(data, k.FileFormatVersion) data = data[entry.RawBytesSize:] k.Entries = append(k.Entries, entry) k.RawBytesSize += entry.RawBytesSize @@ -65,7 +65,7 @@ func (k *Keytab) ToBytes() ([]byte, error) { data = append(data, buffer2...) for _, entry := range k.Entries { - entryBytes, err := entry.ToBytes() + entryBytes, err := entry.ToBytes(k.FileFormatVersion) if err != nil { return nil, err } @@ -81,7 +81,7 @@ func (k *Keytab) ToBytes() ([]byte, error) { // - error: An error if the update fails. func (k *Keytab) UpdateEntriesSizes() error { for i := range k.Entries { - err := k.Entries[i].UpdateSize() + err := k.Entries[i].UpdateSize(k.FileFormatVersion) if err != nil { return err } diff --git a/src/keytab/KeytabEntry.go b/src/keytab/KeytabEntry.go index b3eb5df..4ed45f8 100644 --- a/src/keytab/KeytabEntry.go +++ b/src/keytab/KeytabEntry.go @@ -1,7 +1,6 @@ package keytab import ( - "encoding/binary" "fmt" "strings" "time" @@ -30,43 +29,57 @@ type KeytabEntry struct { // // Returns: // - error: An error if the parsing fails. -func (k *KeytabEntry) FromBytes(data []byte) error { +func (k *KeytabEntry) FromBytes(data []byte, version ...uint16) error { + ver := resolveVersion(version) + bo := byteOrderForVersion(ver) + v1 := isVersion1(ver) + k.RawBytesSize = 0 k.RawBytes = data // Size - k.Size = binary.BigEndian.Uint32(data[0:4]) + k.Size = bo.Uint32(data[0:4]) data = data[4:] data = data[:k.Size] k.RawBytesSize += 4 // NumComponents - k.NumComponents = binary.BigEndian.Uint16(data[0:2]) + numComponents := bo.Uint16(data[0:2]) data = data[2:] k.RawBytesSize += 2 + // In version 1 the on-disk count includes the realm, so subtract 1 to get + // the number of name components. + if v1 && numComponents > 0 { + numComponents-- + } + k.NumComponents = numComponents // Realm k.Realm = CountedOctetString{} - k.Realm.FromBytes(data) + k.Realm.FromBytes(data, bo) data = data[k.Realm.RawBytesSize:] k.RawBytesSize += k.Realm.RawBytesSize // Components for i := uint16(0); i < k.NumComponents; i++ { component := CountedOctetString{} - component.FromBytes(data) + component.FromBytes(data, bo) k.Components = append(k.Components, component) data = data[component.RawBytesSize:] k.RawBytesSize += component.RawBytesSize } - // NameType - k.NameType = binary.BigEndian.Uint32(data[0:4]) - data = data[4:] - k.RawBytesSize += 4 + // NameType (omitted in version 1, which defaults to the principal name type) + if v1 { + k.NameType = nameTypePrincipal + } else { + k.NameType = bo.Uint32(data[0:4]) + data = data[4:] + k.RawBytesSize += 4 + } // Timestamp - k.Timestamp = binary.BigEndian.Uint32(data[0:4]) + k.Timestamp = bo.Uint32(data[0:4]) data = data[4:] k.RawBytesSize += 4 @@ -76,13 +89,13 @@ func (k *KeytabEntry) FromBytes(data []byte) error { k.RawBytesSize += 1 // Key - k.Key.FromBytes(data) + k.Key.FromBytes(data, bo) data = data[k.Key.RawBytesSize:] k.RawBytesSize += k.Key.RawBytesSize // Vno if len(data) >= 4 { - k.Vno = binary.BigEndian.Uint32(data[0:4]) + k.Vno = bo.Uint32(data[0:4]) k.RawBytesSize += 4 // data = data[4:] } else { @@ -99,18 +112,26 @@ func (k *KeytabEntry) FromBytes(data []byte) error { // Returns: // - []byte: The byte array representation of the KeytabEntry. // - error: An error if the conversion fails. -func (k *KeytabEntry) ToBytes() ([]byte, error) { +func (k *KeytabEntry) ToBytes(version ...uint16) ([]byte, error) { + ver := resolveVersion(version) + bo := byteOrderForVersion(ver) + v1 := isVersion1(ver) + data := make([]byte, 0) buffer4 := make([]byte, 4) buffer2 := make([]byte, 2) - // Add the number of components - binary.BigEndian.PutUint16(buffer2, k.NumComponents) + // Add the number of components. Version 1 includes the realm in the count. + numComponents := k.NumComponents + if v1 { + numComponents++ + } + bo.PutUint16(buffer2, numComponents) data = append(data, buffer2...) // Add the realm - realmBytes, err := k.Realm.ToBytes() + realmBytes, err := k.Realm.ToBytes(bo) if err != nil { return nil, err } @@ -118,37 +139,39 @@ func (k *KeytabEntry) ToBytes() ([]byte, error) { // Add the components for _, component := range k.Components { - componentBytes, err := component.ToBytes() + componentBytes, err := component.ToBytes(bo) if err != nil { return nil, err } data = append(data, componentBytes...) } - // Add the name type - binary.BigEndian.PutUint32(buffer4, k.NameType) - data = append(data, buffer4...) + // Add the name type (omitted in version 1) + if !v1 { + bo.PutUint32(buffer4, k.NameType) + data = append(data, buffer4...) + } // Add the timestamp - binary.BigEndian.PutUint32(buffer4, k.Timestamp) + bo.PutUint32(buffer4, k.Timestamp) data = append(data, buffer4...) // Add the vno8 data = append(data, k.Vno8) // Add the key - keyBytes, err := k.Key.ToBytes() + keyBytes, err := k.Key.ToBytes(bo) if err != nil { return nil, err } data = append(data, keyBytes...) // Add the vno - binary.BigEndian.PutUint32(buffer4, k.Vno) + bo.PutUint32(buffer4, k.Vno) data = append(data, buffer4...) // At the start of the data, add the size of the entry - binary.BigEndian.PutUint32(buffer4, uint32(len(data))) + bo.PutUint32(buffer4, uint32(len(data))) data = append(buffer4, data...) return data, nil @@ -158,8 +181,8 @@ func (k *KeytabEntry) ToBytes() ([]byte, error) { // // Returns: // - error: An error if the update fails. -func (k *KeytabEntry) UpdateSize() error { - bytes, err := k.ToBytes() +func (k *KeytabEntry) UpdateSize(version ...uint16) error { + bytes, err := k.ToBytes(version...) if err != nil { return err } diff --git a/src/keytab/Keytab_version1_test.go b/src/keytab/Keytab_version1_test.go new file mode 100644 index 0000000..65c26ae --- /dev/null +++ b/src/keytab/Keytab_version1_test.go @@ -0,0 +1,170 @@ +package keytab + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// makeV1EntryRecord builds, by hand, the raw bytes of a single version-1 keytab +// entry record (little-endian, realm counted in num_components, no name_type) +// for a "foo@ACME" principal with an RC4 key of 0xAABB and a 32-bit vno of 9. +func makeV1EntryRecord() []byte { + le := binary.LittleEndian + body := []byte{} + + put16 := func(v uint16) { b := make([]byte, 2); le.PutUint16(b, v); body = append(body, b...) } + put32 := func(v uint32) { b := make([]byte, 4); le.PutUint32(b, v); body = append(body, b...) } + + put16(2) // num_components: 1 name component + the realm + put16(4) // realm length + body = append(body, []byte("ACME")...) + put16(3) // component length + body = append(body, []byte("foo")...) + // no name_type in version 1 + put32(0x11223344) // timestamp + body = append(body, 0x05) // vno8 + put16(uint16(EncryptionType_RC4_HMAC)) + put16(2) // key length + body = append(body, 0xAA, 0xBB) + put32(9) // 32-bit vno + + size := make([]byte, 4) + le.PutUint32(size, uint32(len(body))) + return append(size, body...) +} + +// Test_KeytabEntry_Version1_Parse parses a hand-built version-1 record and +// verifies the byte order, the component-count adjustment, and the absence of +// name_type are all handled. +func Test_KeytabEntry_Version1_Parse(t *testing.T) { + rec := makeV1EntryRecord() + + entry := KeytabEntry{} + if err := entry.FromBytes(rec, FileFormatVersion1); err != nil { + t.Fatalf("FromBytes failed: %v", err) + } + + if entry.NumComponents != 1 { + t.Errorf("NumComponents: expected 1 (realm subtracted), got %d", entry.NumComponents) + } + if string(entry.Realm.Data) != "ACME" { + t.Errorf("Realm: expected ACME, got %q", entry.Realm.Data) + } + if len(entry.Components) != 1 || string(entry.Components[0].Data) != "foo" { + t.Errorf("Components: expected [foo], got %v", entry.Components) + } + if entry.NameType != nameTypePrincipal { + t.Errorf("NameType: expected default %d, got %d", nameTypePrincipal, entry.NameType) + } + if entry.Timestamp != 0x11223344 { + t.Errorf("Timestamp: expected 0x11223344, got 0x%08x", entry.Timestamp) + } + if entry.Vno8 != 0x05 { + t.Errorf("Vno8: expected 5, got %d", entry.Vno8) + } + if entry.Key.Type != EncryptionType_RC4_HMAC { + t.Errorf("Key.Type: expected RC4_HMAC, got %d", entry.Key.Type) + } + if !bytes.Equal(entry.Key.Key.Data, []byte{0xAA, 0xBB}) { + t.Errorf("Key data: expected aabb, got % x", entry.Key.Key.Data) + } + if entry.Vno != 9 { + t.Errorf("Vno: expected 9, got %d", entry.Vno) + } +} + +// Test_KeytabEntry_Version1_RoundTrip verifies a hand-built version-1 record +// serializes back to the identical bytes. +func Test_KeytabEntry_Version1_RoundTrip(t *testing.T) { + rec := makeV1EntryRecord() + + entry := KeytabEntry{} + if err := entry.FromBytes(rec, FileFormatVersion1); err != nil { + t.Fatalf("FromBytes failed: %v", err) + } + + out, err := entry.ToBytes(FileFormatVersion1) + if err != nil { + t.Fatalf("ToBytes failed: %v", err) + } + if !bytes.Equal(rec, out) { + t.Fatalf("version-1 round-trip changed the bytes:\n in (%d): % x\nout (%d): % x", len(rec), rec, len(out), out) + } +} + +// Test_Keytab_Version1_FileRoundTrip builds a full version-1 keytab in memory, +// serializes it, parses it back, and verifies equality and the on-disk header. +func Test_Keytab_Version1_FileRoundTrip(t *testing.T) { + kt := Keytab{FileFormatVersion: FileFormatVersion1} + kt.Entries = []KeytabEntry{ + { + NumComponents: 1, + Realm: CountedOctetString{Length: 4, Data: []byte("ACME")}, + Components: []CountedOctetString{{Length: 3, Data: []byte("foo")}}, + NameType: nameTypePrincipal, + Timestamp: 0x11223344, + Vno8: 5, + Key: KeyBlock{ + Type: EncryptionType_RC4_HMAC, + Key: CountedOctetString{Length: 2, Data: []byte{0xAA, 0xBB}}, + }, + Vno: 9, + }, + } + if err := kt.UpdateEntriesSizes(); err != nil { + t.Fatalf("UpdateEntriesSizes failed: %v", err) + } + + out, err := kt.ToBytes() + if err != nil { + t.Fatalf("ToBytes failed: %v", err) + } + if out[0] != 0x05 || out[1] != 0x01 { + t.Fatalf("expected version-1 header 0x05 0x01, got 0x%02x 0x%02x", out[0], out[1]) + } + // The on-disk component count is little-endian and includes the realm. + if got := binary.LittleEndian.Uint16(out[6:8]); got != 2 { + t.Fatalf("on-disk num_components: expected 2 (1 component + realm), got %d", got) + } + + kt2 := Keytab{} + if err := kt2.FromBytes(out); err != nil { + t.Fatalf("FromBytes failed: %v", err) + } + if !kt.Equal(&kt2) { + t.Fatalf("version-1 keytab mismatch:\nexpected %+v\ngot %+v", kt, kt2) + } +} + +// Test_KeytabEntry_Version1_OmitsNameType verifies a version-1 entry is exactly +// 4 bytes shorter than the version-2 encoding of the same logical entry (the +// missing 32-bit name_type field). +func Test_KeytabEntry_Version1_OmitsNameType(t *testing.T) { + entry := KeytabEntry{ + NumComponents: 1, + Realm: CountedOctetString{Length: 4, Data: []byte("ACME")}, + Components: []CountedOctetString{{Length: 3, Data: []byte("foo")}}, + NameType: nameTypePrincipal, + Timestamp: 0x11223344, + Vno8: 5, + Key: KeyBlock{ + Type: EncryptionType_RC4_HMAC, + Key: CountedOctetString{Length: 2, Data: []byte{0xAA, 0xBB}}, + }, + Vno: 9, + } + + v1Bytes, err := entry.ToBytes(FileFormatVersion1) + if err != nil { + t.Fatalf("v1 ToBytes failed: %v", err) + } + v2Bytes, err := entry.ToBytes(FileFormatVersion2) + if err != nil { + t.Fatalf("v2 ToBytes failed: %v", err) + } + + if len(v2Bytes)-len(v1Bytes) != 4 { + t.Fatalf("expected version-1 entry to be 4 bytes shorter (no name_type): v1=%d v2=%d", len(v1Bytes), len(v2Bytes)) + } +}