feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The AP-REP the acceptor answers with completes the client half of the
|
||||
// context: it verifies under the session key, and nothing else does.
|
||||
func TestClientAcceptRepRoundTrip(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
client, token, err := ClientInit(EtypeAES256, key, "EXAMPLE.ORG", "nfs", "client")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acceptor, aprep, err := AcceptInit(token, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = acceptor
|
||||
if err := client.ClientAcceptRep(aprep); err != nil {
|
||||
t.Fatalf("accept rep: %v", err)
|
||||
}
|
||||
// The shared key makes both halves sign tokens the other verifies.
|
||||
mic, err := client.GetMIC([]byte("data"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := acceptor.VerifyMIC([]byte("data"), mic); err != nil {
|
||||
t.Fatalf("cross verify: %v", err)
|
||||
}
|
||||
|
||||
// Anything but the genuine AP-REP is refused: a wrong tag, a wrong
|
||||
// message type, a body from another key.
|
||||
if err := client.ClientAcceptRep([]byte{0x6e, 0x00}); err == nil {
|
||||
t.Fatal("a two byte token accepted")
|
||||
}
|
||||
tampered := append([]byte{}, aprep...)
|
||||
tampered[len(tampered)-1] ^= 1
|
||||
if err := client.ClientAcceptRep(tampered); err == nil {
|
||||
t.Fatal("a tampered AP-REP accepted")
|
||||
}
|
||||
other, otoken, err := ClientInit(EtypeAES256, key, "EXAMPLE.ORG", "nfs", "other")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := other.ClientAcceptRep(aprep); err == nil {
|
||||
t.Fatal("an AP-REP of another context accepted")
|
||||
}
|
||||
if bytes.Equal(otoken, token) {
|
||||
t.Fatal("two inits minted the same token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// The AES encryption profiles of RFC 3962 over the simplified profile
|
||||
// of RFC 3961: key derivation DK, the CBC-CTS cipher, and the keyed
|
||||
// checksum, with the key usage constants of RFC 4120 and RFC 4121.
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash"
|
||||
)
|
||||
|
||||
// Encryption types of RFC 3962.
|
||||
const (
|
||||
EtypeAES128 = 17
|
||||
EtypeAES256 = 18
|
||||
)
|
||||
|
||||
// Key usage numbers of RFC 4120 section 7.5.1 and RFC 4121 section 4.
|
||||
const (
|
||||
UsageTicket = 2
|
||||
UsageInitiatorSign = 7
|
||||
UsageAcceptorSign = 8
|
||||
UsageInitiatorSeal = 9
|
||||
UsageAcceptorSeal = 10
|
||||
UsageAPReqAuth = 11
|
||||
UsageAPRepAuth = 12
|
||||
)
|
||||
|
||||
// ErrIntegrity marks a checksum mismatch on decryption.
|
||||
var ErrIntegrity = errors.New("krb5: integrity check failed")
|
||||
|
||||
// deriveKey implements DK of RFC 3961 section 5.1: the constant is
|
||||
// n-folded to the cipher block size, encrypted with the base key until
|
||||
// enough bits accumulate, and the first keySize bytes are the key.
|
||||
func deriveKey(key []byte, constant []byte, keyBytes int) []byte {
|
||||
block := 16
|
||||
folded := NFold(constant, block)
|
||||
var out []byte
|
||||
prev := folded
|
||||
for len(out) < keyBytes {
|
||||
prev = aesECB(key, prev)
|
||||
out = append(out, prev...)
|
||||
}
|
||||
return out[:keyBytes]
|
||||
}
|
||||
|
||||
// DK derives the protocol key for a usage: the constant is the usage
|
||||
// number as four big endian octets followed by the label octet.
|
||||
func DK(key []byte, usage uint32, label byte) []byte {
|
||||
var constant [5]byte
|
||||
binary.BigEndian.PutUint32(constant[:4], usage)
|
||||
constant[4] = label
|
||||
return deriveKey(key, constant[:], len(key))
|
||||
}
|
||||
|
||||
// deriveKeys answers the three keys of the simplified profile:
|
||||
// Kc for checksums, Ke for encryption, Ki for the message HMAC.
|
||||
func deriveKeys(key []byte, usage uint32) (kc, ke, ki []byte) {
|
||||
return DK(key, usage, 0x99), DK(key, usage, 0xAA), DK(key, usage, 0x55)
|
||||
}
|
||||
|
||||
// aesECB encrypts a block aligned input in ECB mode, which is how the
|
||||
// profile uses the cipher for key derivation.
|
||||
func aesECB(key, block []byte) []byte {
|
||||
c, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic("krb5: " + err.Error())
|
||||
}
|
||||
out := make([]byte, len(block))
|
||||
for i := 0; i < len(block); i += c.BlockSize() {
|
||||
c.Encrypt(out[i:], block[i:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ctsEncrypt implements the Kerberos CBC-CTS mode of RFC 3962: plain
|
||||
// CBC for everything but the tail, with the last two blocks through
|
||||
// ciphertext stealing. The output carries the input length, except an
|
||||
// ctsEncrypt implements the Kerberos CBC-CTS mode of RFC 3962, ported
|
||||
// from the MIT krb5 reference: plain CBC over everything but the last
|
||||
// two blocks, whose ciphertexts swap places, with the final block
|
||||
// truncated back to the input length.
|
||||
func ctsEncrypt(key []byte, plaintext []byte) []byte {
|
||||
c, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic("krb5: " + err.Error())
|
||||
}
|
||||
n := len(plaintext)
|
||||
if n == 0 {
|
||||
panic("krb5: empty plaintext")
|
||||
}
|
||||
blocks := (n + 15) / 16
|
||||
lastLen := n - (blocks-1)*16
|
||||
if blocks == 1 {
|
||||
block := make([]byte, 16)
|
||||
copy(block, plaintext)
|
||||
out := make([]byte, 16)
|
||||
c.Encrypt(out, block)
|
||||
return out
|
||||
}
|
||||
buf := make([]byte, blocks*16)
|
||||
copy(buf, plaintext)
|
||||
head := buf[:(blocks-2)*16]
|
||||
iv := make([]byte, 16)
|
||||
cipher.NewCBCEncrypter(c, iv).CryptBlocks(head, head)
|
||||
prev := make([]byte, 16)
|
||||
if blocks > 2 {
|
||||
copy(prev, head[len(head)-16:])
|
||||
}
|
||||
p1 := buf[(blocks-2)*16 : (blocks-1)*16]
|
||||
p2 := buf[(blocks-1)*16:]
|
||||
c1 := make([]byte, 16)
|
||||
for i := range c1 {
|
||||
c1[i] = p1[i] ^ prev[i]
|
||||
}
|
||||
c.Encrypt(c1, c1)
|
||||
c2 := make([]byte, 16)
|
||||
for i := range c2 {
|
||||
c2[i] = p2[i] ^ c1[i]
|
||||
}
|
||||
c.Encrypt(c2, c2)
|
||||
out := make([]byte, 0, n)
|
||||
out = append(out, head...)
|
||||
out = append(out, c2...)
|
||||
out = append(out, c1[:lastLen]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ctsDecrypt reverses ctsEncrypt.
|
||||
func ctsDecrypt(key []byte, ciphertext []byte) []byte {
|
||||
c, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic("krb5: " + err.Error())
|
||||
}
|
||||
n := len(ciphertext)
|
||||
blocks := (n + 15) / 16
|
||||
lastLen := n - (blocks-1)*16
|
||||
if blocks == 1 {
|
||||
block := make([]byte, 16)
|
||||
copy(block, ciphertext)
|
||||
out := make([]byte, 16)
|
||||
c.Decrypt(out, block)
|
||||
return out
|
||||
}
|
||||
buf := make([]byte, blocks*16)
|
||||
copy(buf, ciphertext)
|
||||
head := buf[:(blocks-2)*16]
|
||||
prev := make([]byte, 16)
|
||||
if blocks > 2 {
|
||||
copy(prev, head[len(head)-16:])
|
||||
}
|
||||
if len(head) > 0 {
|
||||
cipher.NewCBCDecrypter(c, make([]byte, 16)).CryptBlocks(head, head)
|
||||
}
|
||||
blockN2 := append([]byte{}, buf[(blocks-2)*16:(blocks-1)*16]...)
|
||||
blockN1 := append([]byte{}, buf[(blocks-1)*16:]...)
|
||||
blockN1 = append(blockN1, make([]byte, 16-len(blockN1))...)
|
||||
// The final plaintext block decrypts with the truncated ciphertext
|
||||
// block as the CBC vector.
|
||||
p2 := make([]byte, 16)
|
||||
c.Decrypt(p2, blockN2)
|
||||
for i := range p2 {
|
||||
p2[i] ^= blockN1[i]
|
||||
}
|
||||
// The stolen tail of the decrypted final block restores the truncated
|
||||
// second-to-last ciphertext block.
|
||||
for i := lastLen; i < 16; i++ {
|
||||
blockN1[i] = p2[i]
|
||||
}
|
||||
p1 := make([]byte, 16)
|
||||
c.Decrypt(p1, blockN1)
|
||||
for i := range p1 {
|
||||
p1[i] ^= prev[i]
|
||||
}
|
||||
out := make([]byte, 0, n)
|
||||
out = append(out, head...)
|
||||
out = append(out, p1...)
|
||||
out = append(out, p2[:lastLen]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// Encrypt applies the RFC 3962 simplified profile over AES: a random
|
||||
// confounder, the plaintext, and a truncated HMAC under Ki, the whole
|
||||
// confounder and plaintext encrypted under Ke by CBC-CTS.
|
||||
func Encrypt(etype uint32, key []byte, usage uint32, plaintext []byte) ([]byte, error) {
|
||||
_, ke, ki := deriveKeys(key, usage)
|
||||
conf := make([]byte, 16)
|
||||
if _, err := rand.Read(conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := append(append([]byte{}, conf...), plaintext...)
|
||||
body := ctsEncrypt(ke, data)
|
||||
mac := hmac.New(sha1.New, ki)
|
||||
mac.Write(data)
|
||||
return append(body, mac.Sum(nil)[:12]...), nil
|
||||
}
|
||||
|
||||
// Decrypt verifies and removes the protection Encrypt applied.
|
||||
func Decrypt(etype uint32, key []byte, usage uint32, ciphertext []byte) ([]byte, error) {
|
||||
_, ke, ki := deriveKeys(key, usage)
|
||||
if len(ciphertext) < 16+12 {
|
||||
return nil, ErrIntegrity
|
||||
}
|
||||
body := ciphertext[:len(ciphertext)-12]
|
||||
mac := ciphertext[len(ciphertext)-12:]
|
||||
data := ctsDecrypt(ke, body)
|
||||
expect := hmac.New(sha1.New, ki)
|
||||
expect.Write(data)
|
||||
if !hmac.Equal(expect.Sum(nil)[:12], mac) {
|
||||
return nil, ErrIntegrity
|
||||
}
|
||||
return data[16:], nil
|
||||
}
|
||||
|
||||
// Checksum answers the keyed checksum of RFC 3961 section 5.4: the
|
||||
// leading twelve bytes of HMAC-SHA1 under Kc.
|
||||
func Checksum(etype uint32, key []byte, usage uint32, message []byte) ([]byte, error) {
|
||||
kc, _, _ := deriveKeys(key, usage)
|
||||
mac := hmac.New(sha1.New, kc)
|
||||
mac.Write(message)
|
||||
return mac.Sum(nil)[:12], nil
|
||||
}
|
||||
|
||||
// StringToKey implements the PBKDF2+DK function of RFC 3962 section 4:
|
||||
// PBKDF2 over HMAC-SHA1 with the given iteration count, then one
|
||||
// application of the key derivation with the well known constant
|
||||
// "kerberos" of the AES profile.
|
||||
func StringToKey(etype uint32, password, salt []byte, iterations uint32, keyBytes int) []byte {
|
||||
_ = etype
|
||||
seed := pbkdf2(sha1.New, password, salt, int(iterations), keyBytes)
|
||||
pepper := []byte("kerberos")
|
||||
return deriveKey(seed, pepper, keyBytes)
|
||||
}
|
||||
|
||||
// pbkdf2 is the PBKDF2 of RFC 2898 over the given hash.
|
||||
func pbkdf2(h func() hash.Hash, password, salt []byte, rounds, length int) []byte {
|
||||
out := make([]byte, 0, length)
|
||||
var block [4]byte
|
||||
for blockIndex := 1; len(out) < length; blockIndex++ {
|
||||
block[0] = byte(blockIndex >> 24)
|
||||
block[1] = byte(blockIndex >> 16)
|
||||
block[2] = byte(blockIndex >> 8)
|
||||
block[3] = byte(blockIndex)
|
||||
mac := hmac.New(sha1.New, password)
|
||||
mac.Write(salt)
|
||||
mac.Write(block[:])
|
||||
u := mac.Sum(nil)
|
||||
t := make([]byte, len(u))
|
||||
copy(t, u)
|
||||
for i := 1; i < rounds; i++ {
|
||||
mac.Reset()
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
out = append(out, t...)
|
||||
}
|
||||
return out[:length]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func unhex(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// The n-fold test vectors of RFC 3961 appendix A.
|
||||
func TestNFoldVectors(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
bits int
|
||||
want string
|
||||
}{
|
||||
{"303132333435", 64, "be072631276b1955"},
|
||||
{"70617373776f7264", 56, "78a07b6caf85fa"},
|
||||
{"526f75676820436f6e73656e7375732c20616e642052756e" +
|
||||
"6e696e6720436f6465", 64, "bb6ed30870b7f0e0"},
|
||||
{"70617373776f7264", 168, "59e4a8ca7c0385c3c37b3f6d2000247cb6e6bd5b3e"},
|
||||
{"4d41535341434856534554545320494e5354495456544520" +
|
||||
"4f4620544543484e4f4c4f4759", 192,
|
||||
"db3b0d8f0b061e603282b308a50841229ad798fab9540c1b"},
|
||||
{"51", 168, "518a54a215a8452a518a54a215a8452a518a54a215"},
|
||||
{"6261", 168, "fb25d531ae8974499f52fd92ea9857c4ba24cf297e"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := NFold(unhex(t, c.in), c.bits/8)
|
||||
if !bytes.Equal(got, unhex(t, c.want)) {
|
||||
t.Errorf("nfold %d bits of %s: % x, want %s", c.bits, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The key derivation test values of the MIT krb5 reference suite: the
|
||||
// AES-128 key with the checksum and encryption constants of usage two.
|
||||
func TestDeriveVector(t *testing.T) {
|
||||
key := unhex(t, "42263c6e89f4fc28b8df68ee09799f15")
|
||||
kc := DK(key, 2, 0x99)
|
||||
if !bytes.Equal(kc, unhex(t, "34280a382bc92769b2da2f9ef066854b")) {
|
||||
t.Fatalf("Kc % x", kc)
|
||||
}
|
||||
}
|
||||
|
||||
// The PBKDF2 string-to-key vectors of RFC 3962 appendix B, and the
|
||||
// resulting protocol keys.
|
||||
func TestStringToKeyVectors(t *testing.T) {
|
||||
salt := []byte("ATHENA.MIT.EDUraeburn")
|
||||
k128 := StringToKey(EtypeAES128, []byte("password"), salt, 1, 16)
|
||||
if !bytes.Equal(k128, unhex(t, "42263c6e89f4fc28b8df68ee09799f15")) {
|
||||
t.Fatalf("aes128 key % x", k128)
|
||||
}
|
||||
k256 := StringToKey(EtypeAES256, []byte("password"), salt, 1, 32)
|
||||
if !bytes.Equal(k256, unhex(t, "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161")) {
|
||||
t.Fatalf("aes256 key % x", k256)
|
||||
}
|
||||
k2 := StringToKey(EtypeAES256, []byte("password"), salt, 2, 32)
|
||||
if !bytes.Equal(k2, unhex(t, "a2e16d16b36069c135d5e9d2e25f896102685618b95914b467c67622225824ff")) {
|
||||
t.Fatalf("aes256 two rounds % x", k2)
|
||||
}
|
||||
}
|
||||
|
||||
// The checksum test value of the MIT krb5 reference suite: HMAC-SHA1-96
|
||||
// under the derived checksum key of usage three.
|
||||
func TestChecksumVector(t *testing.T) {
|
||||
key := unhex(t, "9062430c8cda3388922e6d6a509f5b7a")
|
||||
sum, err := Checksum(EtypeAES128, key, 3, []byte("eight nine ten eleven twelve thirteen"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(sum, unhex(t, "01a4b088d45628f6946614e3")) {
|
||||
t.Fatalf("checksum % x", sum)
|
||||
}
|
||||
}
|
||||
|
||||
// The profile round trips at both key sizes and over lengths that walk
|
||||
// the CTS edge cases.
|
||||
func TestEncryptRoundTrip(t *testing.T) {
|
||||
key := unhex(t, "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161")
|
||||
for _, size := range []int{0, 1, 15, 16, 17, 31, 32, 33, 100, 1000} {
|
||||
plain := make([]byte, size)
|
||||
for i := range plain {
|
||||
plain[i] = byte(i)
|
||||
}
|
||||
ct, err := Encrypt(EtypeAES256, key, UsageInitiatorSeal, plain)
|
||||
if err != nil {
|
||||
t.Fatalf("size %d: %v", size, err)
|
||||
}
|
||||
got, err := Decrypt(EtypeAES256, key, UsageInitiatorSeal, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("size %d: %v", size, err)
|
||||
}
|
||||
if !bytes.Equal(got, plain) {
|
||||
t.Fatalf("size %d: round trip differs", size)
|
||||
}
|
||||
// One flipped byte must break the integrity check.
|
||||
ct[len(ct)/2] ^= 0xff
|
||||
if _, err := Decrypt(EtypeAES256, key, UsageInitiatorSeal, ct); err == nil {
|
||||
t.Fatalf("size %d: tampering passed", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// A minimal DER encoder and decoder for the Kerberos protocol messages
|
||||
// of RFC 4120: just the constructs the AP-REQ and AP-REP exchanges of
|
||||
// the GSS context establishment need.
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// DER tag octets used below.
|
||||
const (
|
||||
tagInteger = 0x02
|
||||
tagBitString = 0x03
|
||||
tagOctet = 0x04
|
||||
tagNull = 0x05
|
||||
tagOID = 0x06
|
||||
tagSequence = 0x30
|
||||
tagGeneral = 0x80 // the high bits of a context or application tag
|
||||
)
|
||||
|
||||
// ErrDER marks a malformed DER input.
|
||||
var ErrDER = errors.New("krb5: malformed DER")
|
||||
|
||||
// derLen encodes a DER length octet string.
|
||||
func derLen(n int) []byte {
|
||||
if n < 0x80 {
|
||||
return []byte{byte(n)}
|
||||
}
|
||||
var buf [8]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte(n)
|
||||
n >>= 8
|
||||
}
|
||||
return append([]byte{0x80 | byte(len(buf)-i)}, buf[i:]...)
|
||||
}
|
||||
|
||||
// derTLV wraps a payload in one tag-length-value record.
|
||||
func derTLV(tag byte, payload []byte) []byte {
|
||||
return append(append([]byte{tag}, derLen(len(payload))...), payload...)
|
||||
}
|
||||
|
||||
// derInt encodes a DER integer.
|
||||
func derInt(v uint64) []byte {
|
||||
var raw [8]byte
|
||||
binary.BigEndian.PutUint64(raw[:], v)
|
||||
i := 0
|
||||
for i < 7 && raw[i] == 0 && raw[i+1]&0x80 == 0 {
|
||||
i++
|
||||
}
|
||||
for i < 8 && raw[i] == 0xff && i+1 < 8 && raw[i+1]&0x80 != 0 {
|
||||
i++
|
||||
}
|
||||
return derTLV(tagInteger, raw[i:])
|
||||
}
|
||||
|
||||
// derGeneralString encodes a Kerberos string.
|
||||
func derGeneralString(s string) []byte {
|
||||
return derTLV(0x1b, []byte(s))
|
||||
}
|
||||
|
||||
// derOID encodes the krb5 mechanism object identifier.
|
||||
var derOID = []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02}
|
||||
|
||||
// derReader walks a DER structure.
|
||||
type derReader struct {
|
||||
b []byte
|
||||
off int
|
||||
}
|
||||
|
||||
func newDERReader(b []byte) *derReader { return &derReader{b: b} }
|
||||
|
||||
// next reads one TLV and answers the tag and payload.
|
||||
func (r *derReader) next() (byte, []byte, error) {
|
||||
if r.off+2 > len(r.b) {
|
||||
return 0, nil, ErrDER
|
||||
}
|
||||
tag := r.b[r.off]
|
||||
length := int(r.b[r.off+1])
|
||||
start := r.off + 2
|
||||
if length&0x80 != 0 {
|
||||
n := length & 0x7f
|
||||
if n == 0 || n > 4 || start+n > len(r.b) {
|
||||
return 0, nil, ErrDER
|
||||
}
|
||||
length = 0
|
||||
for i := range n {
|
||||
length = length<<8 | int(r.b[start+i])
|
||||
}
|
||||
start += n
|
||||
}
|
||||
if start+length > len(r.b) {
|
||||
return 0, nil, ErrDER
|
||||
}
|
||||
r.off = start + length
|
||||
return tag, r.b[start : start+length], nil
|
||||
}
|
||||
|
||||
// expect reads one TLV and requires the tag.
|
||||
func (r *derReader) expect(tag byte) ([]byte, error) {
|
||||
got, payload, err := r.next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got != tag {
|
||||
return nil, ErrDER
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// derUint decodes a DER integer payload.
|
||||
func derUint(payload []byte) (uint64, error) {
|
||||
if len(payload) == 0 || len(payload) > 8 {
|
||||
return 0, ErrDER
|
||||
}
|
||||
var v uint64
|
||||
for _, b := range payload {
|
||||
v = v<<8 | uint64(b)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// derBitString decodes a DER bit string payload: the leading unused bit
|
||||
// count octet followed by the bits.
|
||||
func derBitString(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, ErrDER
|
||||
}
|
||||
return payload[1:], nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzAcceptInit feeds arbitrary context establishment tokens through
|
||||
// the acceptor: no input may panic the DER walk or the crypto, and a
|
||||
// forged token must fail closed.
|
||||
func FuzzAcceptInit(f *testing.F) {
|
||||
key := make([]byte, 32)
|
||||
_, token, err := ClientInit(EtypeAES256, key, "EXAMPLE.ORG", "nfs", "probe")
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
f.Add(token)
|
||||
f.Add([]byte{0x6e, 0x00})
|
||||
f.Add([]byte{0x6e, 0x20, 0x30, 0x1d, 0x02})
|
||||
f.Add(make([]byte, 32))
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
// The property under test is that the acceptor never panics;
|
||||
// anything but a genuine token is an error.
|
||||
_, _, _ = AcceptInit(data, key)
|
||||
_ = (&Context{Etype: EtypeAES256, Key: key}).ClientAcceptRep(data)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// The Kerberos V5 GSS-API mechanism of RFC 4121 over the raw krb5
|
||||
// mechanism of RFC 1964 framing: the context establishment through
|
||||
// AP-REQ and AP-REP (RFC 4120), and the per-message MIC and Wrap
|
||||
// tokens (RFC 4121 sections 4.2.4 to 4.2.6).
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GSS token identifiers and flag bits of RFC 4121.
|
||||
const (
|
||||
TokenMIC = 0x0404
|
||||
TokenWrap = 0x0504
|
||||
|
||||
FlagSentByAcceptor = 0x01
|
||||
FlagSealed = 0x02
|
||||
FlagAcceptorSubkey = 0x04
|
||||
)
|
||||
|
||||
// clockSkew is the window the acceptor tolerates between the client
|
||||
// clock and its own, five minutes, the customary maximum of RFC 4120
|
||||
// section 10.1.
|
||||
const clockSkew = 5 * time.Minute
|
||||
|
||||
// ErrToken marks a malformed or unusable GSS token.
|
||||
var ErrToken = errors.New("krb5: bad gss token")
|
||||
|
||||
// ErrContext marks a mismatch against the established context.
|
||||
var ErrContext = errors.New("krb5: context mismatch")
|
||||
|
||||
// ErrReplay marks an authenticator the acceptor has already seen.
|
||||
var ErrReplay = errors.New("krb5: replayed token")
|
||||
|
||||
// ErrExpired marks a ticket whose endtime has passed.
|
||||
var ErrExpired = errors.New("krb5: ticket expired")
|
||||
|
||||
// ErrSkew marks an authenticator outside the clock skew window.
|
||||
var ErrSkew = errors.New("krb5: clock skew too great")
|
||||
|
||||
// A Context is one established security context: the session key, the
|
||||
// etype, the per-peer sequence counters and the acceptor role. The
|
||||
// counters and the token operations are guarded by the internal mutex, so
|
||||
// one context serves concurrent callers.
|
||||
type Context struct {
|
||||
Key []byte
|
||||
Etype uint32
|
||||
Accepting bool
|
||||
SendSeq uint64
|
||||
RecvSeq uint64
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// appTag wraps a payload in the DER application tag of RFC 4120.
|
||||
func appTag(number byte, payload []byte) []byte {
|
||||
return derTLV(0x60|number, payload)
|
||||
}
|
||||
|
||||
// principalName encodes a PrincipalName of name type one, the name of
|
||||
// a single component service.
|
||||
func principalName(name string) []byte {
|
||||
names := derTLV(tagSequence, derGeneralString(name))
|
||||
return derTLV(tagSequence, append(derInt(1), names...))
|
||||
}
|
||||
|
||||
// encryptedData encodes an EncryptedData with a version number.
|
||||
func encryptedData(etype uint32, cipher []byte, kvno uint64) []byte {
|
||||
body := append(derInt(uint64(etype)), derInt(kvno)...)
|
||||
body = append(body, derTLV(tagOctet, cipher)...)
|
||||
return derTLV(tagSequence, body)
|
||||
}
|
||||
|
||||
// checksum encodes a Checksum of type 0x8003, the GSS-API checksum.
|
||||
func gssChecksum(sum []byte) []byte {
|
||||
return derTLV(tagSequence, append(derInt(0x8003), derTLV(tagOctet, sum)...))
|
||||
}
|
||||
|
||||
// bitFlags encodes the BIT STRING form of the Kerberos flags.
|
||||
func bitFlags(flags uint32) []byte {
|
||||
var bits [6]byte
|
||||
binary.BigEndian.PutUint32(bits[2:], flags)
|
||||
bits[0] = 5 // construction
|
||||
bits[1] = 0 // unused bit count; the leading octets of a 32 bit field
|
||||
bits[2] = 0
|
||||
bits[3] = byte(flags >> 24)
|
||||
bits[4] = byte(flags >> 16)
|
||||
bits[5] = byte(flags >> 8)
|
||||
bits[5] = byte(flags)
|
||||
return derTLV(tagBitString, append([]byte{0}, bits[2:6]...))
|
||||
}
|
||||
|
||||
// kerberosTime encodes a GeneralizedTime of whole seconds.
|
||||
func kerberosTime(t time.Time) []byte {
|
||||
return derTLV(0x18, []byte(t.UTC().Format("20060102150405Z")))
|
||||
}
|
||||
|
||||
// parseKerberosTime reads the GeneralizedTime of whole seconds.
|
||||
func parseKerberosTime(payload []byte) (time.Time, error) {
|
||||
t, err := time.Parse("20060102150405Z", string(payload))
|
||||
if err != nil {
|
||||
return time.Time{}, ErrToken
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// buildTicket produces the ticket part of an AP-REQ: the encrypted part
|
||||
// carries the session key under the service long term key, valid until
|
||||
// the given endtime.
|
||||
func buildTicket(realm, service string, session, serviceKey []byte, etype uint32, endtime time.Time) ([]byte, error) {
|
||||
flags := bitFlags(0)
|
||||
key := derTLV(tagSequence, append(derInt(uint64(etype)), derTLV(tagOctet, session)...))
|
||||
inner := append(append(append(append([]byte{}, flags...), key...),
|
||||
derGeneralString(realm)...), principalName(service)...)
|
||||
inner = append(inner, derTLV(tagSequence, nil)...) // transited: empty
|
||||
inner = append(inner, kerberosTime(time.Unix(0, 0))...) // authtime
|
||||
inner = append(inner, kerberosTime(endtime)...) // endtime
|
||||
inner = append(inner, derGeneralString(realm)...) // srealm
|
||||
inner = append(inner, principalName(service)...) // sname
|
||||
cipher, cerr := Encrypt(etype, serviceKey, UsageTicket, inner)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
ticket := appTag(1, derTLV(tagSequence, append(append(
|
||||
derInt(5), derGeneralString(realm)...),
|
||||
append(principalName(service), encryptedData(etype, cipher, 1)...)...)))
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// buildAuthenticator produces the encrypted authenticator of an AP-REQ
|
||||
// for the given client time, with the 0x8003 checksum of RFC 4121
|
||||
// section 4.1.1: the little endian length of Bnd, the sixteen Bnd octets
|
||||
// of an unused channel binding, and the little endian Flags, so a
|
||||
// conformant acceptor can read it.
|
||||
func buildAuthenticator(realm, client string, session []byte, etype uint32, seq uint64, ctime time.Time) ([]byte, error) {
|
||||
inner := append(derInt(5), derGeneralString(realm)...)
|
||||
inner = append(inner, principalName(client)...)
|
||||
sum := make([]byte, 0, 24)
|
||||
var word [4]byte
|
||||
binary.LittleEndian.PutUint32(word[:], 16)
|
||||
sum = append(sum, word[:]...) // length of Bnd
|
||||
sum = append(sum, make([]byte, 16)...) // Bnd: no channel binding
|
||||
binary.LittleEndian.PutUint32(word[:], 0)
|
||||
sum = append(sum, word[:]...) // Flags: none
|
||||
inner = append(inner, gssChecksum(sum)...)
|
||||
inner = append(inner, derInt(uint64(ctime.Nanosecond())/1000)...)
|
||||
inner = append(inner, kerberosTime(ctime)...)
|
||||
inner = append(inner, derInt(seq)...)
|
||||
cipher, err := Encrypt(etype, session, UsageAPReqAuth, inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encryptedData(etype, cipher, 0), nil
|
||||
}
|
||||
|
||||
// ClientInit builds the context establishment token the initiator sends
|
||||
// in RPCSEC_GSS_INIT: the GSS framed AP-REQ. The service key is the
|
||||
// long term key the ticket is sealed with, as a KDC would have it.
|
||||
func ClientInit(etype uint32, serviceKey []byte, realm, service, client string) (*Context, []byte, error) {
|
||||
session := make([]byte, len(serviceKey))
|
||||
if _, err := rand.Read(session); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctx := &Context{Key: session, Etype: etype}
|
||||
ticket, err := buildTicket(realm, service, session, serviceKey, etype, time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
auth, err := buildAuthenticator(realm, client, session, etype, 0, time.Now())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
apreq := appTag(14, derTLV(tagSequence, append(append(
|
||||
derInt(5), derInt(14)...),
|
||||
append(bitFlags(0), append(ticket, auth...)...)...)))
|
||||
inner := append(append([]byte{}, derOID...), apreq...)
|
||||
return ctx, appTag(0, inner), nil
|
||||
}
|
||||
|
||||
// checkGSSChecksum validates the 0x8003 checksum body of RFC 4121
|
||||
// section 4.1.1: the little endian length of Bnd, the sixteen Bnd octets
|
||||
// and the little endian Flags.
|
||||
func checkGSSChecksum(payload []byte) error {
|
||||
r := newDERReader(payload)
|
||||
typeRaw, err := r.expect(tagInteger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctype, err := derUint(typeRaw)
|
||||
if err != nil || ctype != 0x8003 {
|
||||
return ErrToken
|
||||
}
|
||||
body, err := r.expect(tagOctet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) != 24 || binary.LittleEndian.Uint32(body[0:4]) != 16 {
|
||||
return ErrToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePrincipal reads a PrincipalName and answers its components joined
|
||||
// with a slash.
|
||||
func parsePrincipal(payload []byte) (string, error) {
|
||||
r := newDERReader(payload)
|
||||
if _, err := r.expect(tagInteger); err != nil { // name type
|
||||
return "", err
|
||||
}
|
||||
strs, err := r.expect(tagSequence)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sr := newDERReader(strs)
|
||||
var parts []string
|
||||
for {
|
||||
tag, raw, err := sr.next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if tag != 0x1b {
|
||||
return "", ErrDER
|
||||
}
|
||||
parts = append(parts, string(raw))
|
||||
}
|
||||
return strings.Join(parts, "/"), nil
|
||||
}
|
||||
|
||||
// parseAuthenticator reads the fields of a decrypted authenticator the
|
||||
// acceptor checks: the client principal, the timestamp, the microseconds
|
||||
// and the sequence number when one is present.
|
||||
func parseAuthenticator(plain []byte) (client string, ctime time.Time, cusec uint64, seq int64, err error) {
|
||||
r := newDERReader(plain)
|
||||
vnoRaw, err := r.expect(tagInteger)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
vno, err := derUint(vnoRaw)
|
||||
if err != nil || vno != 5 {
|
||||
err = ErrToken
|
||||
return
|
||||
}
|
||||
if _, err = r.expect(0x1b); err != nil { // crealm
|
||||
return
|
||||
}
|
||||
cnameRaw, err := r.expect(tagSequence)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if client, err = parsePrincipal(cnameRaw); err != nil {
|
||||
return
|
||||
}
|
||||
cksumRaw, err := r.expect(tagSequence) // the GSS checksum, required
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err = checkGSSChecksum(cksumRaw); err != nil {
|
||||
return
|
||||
}
|
||||
cusecRaw, err := r.expect(tagInteger)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cusec, err = derUint(cusecRaw); err != nil {
|
||||
return
|
||||
}
|
||||
ctimeRaw, err := r.expect(0x18)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if ctime, err = parseKerberosTime(ctimeRaw); err != nil {
|
||||
return
|
||||
}
|
||||
// Past the timestamp this profile writes at most the sequence number,
|
||||
// an integer; anything else ends the fields it reads.
|
||||
if tag, payload, terr := r.next(); terr == nil && tag == tagInteger {
|
||||
v, verr := derUint(payload)
|
||||
if verr != nil {
|
||||
err = ErrToken
|
||||
return
|
||||
}
|
||||
seq = int64(v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// replayKey identifies one authenticator of one client: the principal
|
||||
// plus the timestamp, microseconds and sequence number it carried.
|
||||
type replayKey struct {
|
||||
client string
|
||||
ctime int64
|
||||
cusec int64
|
||||
seq int64
|
||||
}
|
||||
|
||||
// replays is the acceptor wide replay cache. Entries live for the clock
|
||||
// skew window, beyond which a token is refused for its age anyway.
|
||||
var replays = struct {
|
||||
mu sync.Mutex
|
||||
seen map[replayKey]time.Time
|
||||
}{seen: make(map[replayKey]time.Time)}
|
||||
|
||||
// seenReplay records the authenticator and reports whether one with the
|
||||
// same key was already accepted.
|
||||
func seenReplay(k replayKey) bool {
|
||||
now := time.Now()
|
||||
replays.mu.Lock()
|
||||
defer replays.mu.Unlock()
|
||||
for key, until := range replays.seen {
|
||||
if now.After(until) {
|
||||
delete(replays.seen, key)
|
||||
}
|
||||
}
|
||||
if _, ok := replays.seen[k]; ok {
|
||||
return true
|
||||
}
|
||||
replays.seen[k] = now.Add(clockSkew)
|
||||
return false
|
||||
}
|
||||
|
||||
// AcceptInit verifies the initiator token and answers the AP-REP and
|
||||
// the established context of the acceptor side. The ticket must not have
|
||||
// expired, the authenticator must sit inside the clock skew window and
|
||||
// must never have been accepted before.
|
||||
func AcceptInit(token []byte, serviceKey []byte) (*Context, []byte, error) {
|
||||
r := newDERReader(token)
|
||||
outer, err := r.expect(0x60 | 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ir := newDERReader(outer)
|
||||
if _, err = ir.expect(tagOID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
apreq, err := ir.expect(0x60 | 14)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sr := newDERReader(apreq)
|
||||
seqPayload, err := sr.expect(tagSequence)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
br := newDERReader(seqPayload)
|
||||
if _, err = br.expect(tagInteger); err != nil { // pvno
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = br.expect(tagInteger); err != nil { // msg-type
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = br.expect(tagBitString); err != nil { // ap-options
|
||||
return nil, nil, err
|
||||
}
|
||||
ticketRaw, err := br.expect(0x60 | 1)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
authRaw, err := br.expect(tagSequence)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The ticket: decrypt the encrypted part with the service key.
|
||||
tr := newDERReader(ticketRaw)
|
||||
ticketSeq, terr := tr.expect(tagSequence)
|
||||
if terr != nil {
|
||||
return nil, nil, terr
|
||||
}
|
||||
t := newDERReader(ticketSeq)
|
||||
if _, err = t.expect(tagInteger); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = t.expect(0x1b); err != nil { // realm
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = t.expect(tagSequence); err != nil { // sname
|
||||
return nil, nil, err
|
||||
}
|
||||
encTag, encPayload, err := t.next()
|
||||
if err != nil || encTag != tagSequence {
|
||||
return nil, nil, ErrDER
|
||||
}
|
||||
er := newDERReader(encPayload)
|
||||
_, epayload, eerr := er.next()
|
||||
if eerr != nil {
|
||||
return nil, nil, eerr
|
||||
}
|
||||
etype, eerr2 := derUint(epayload)
|
||||
if eerr2 != nil {
|
||||
return nil, nil, eerr2
|
||||
}
|
||||
if _, err = er.expect(tagInteger); err != nil { // kvno
|
||||
return nil, nil, err
|
||||
}
|
||||
cipherPayload, err := er.expect(tagOctet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
inner, err := Decrypt(uint32(etype), serviceKey, UsageTicket, cipherPayload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The decrypted ticket part: flags, key, crealm, cname, transited,
|
||||
// authtime, endtime, srealm, sname. The key and the endtime matter
|
||||
// here; this profile writes no starttime, so the second time is the
|
||||
// endtime.
|
||||
ir2 := newDERReader(inner)
|
||||
if _, err = ir2.expect(tagBitString); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, keyPayload, err := ir2.next()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
kr := newDERReader(keyPayload)
|
||||
if _, err = kr.expect(tagInteger); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyValue, err := kr.expect(tagOctet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = ir2.expect(0x1b); err != nil { // crealm
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = ir2.expect(tagSequence); err != nil { // cname
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = ir2.expect(tagSequence); err != nil { // transited
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = ir2.expect(0x18); err != nil { // authtime
|
||||
return nil, nil, err
|
||||
}
|
||||
endRaw, err := ir2.expect(0x18) // endtime
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
endtime, err := parseKerberosTime(endRaw)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if time.Now().After(endtime) {
|
||||
return nil, nil, ErrExpired
|
||||
}
|
||||
// The authenticator: decrypt with the session key.
|
||||
ar := newDERReader(authRaw)
|
||||
atag, apayload, aerr := ar.next()
|
||||
if aerr != nil {
|
||||
return nil, nil, aerr
|
||||
}
|
||||
if atag != tagInteger {
|
||||
return nil, nil, ErrDER
|
||||
}
|
||||
authEtype, aerr2 := derUint(apayload)
|
||||
if aerr2 != nil {
|
||||
return nil, nil, aerr2
|
||||
}
|
||||
if _, err = ar.expect(tagInteger); err != nil { // kvno
|
||||
return nil, nil, err
|
||||
}
|
||||
authCipher, err := ar.expect(tagOctet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
plain, err := Decrypt(uint32(authEtype), keyValue, UsageAPReqAuth, authCipher)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, ctime, cusec, seq, perr := parseAuthenticator(plain)
|
||||
if perr != nil {
|
||||
return nil, nil, perr
|
||||
}
|
||||
authTime := ctime.Add(time.Duration(cusec) * time.Microsecond)
|
||||
if d := time.Since(authTime); d > clockSkew || d < -clockSkew {
|
||||
return nil, nil, ErrSkew
|
||||
}
|
||||
if seenReplay(replayKey{client: client, ctime: ctime.Unix(), cusec: int64(cusec), seq: seq}) {
|
||||
return nil, nil, ErrReplay
|
||||
}
|
||||
ctx := &Context{Key: keyValue, Etype: uint32(authEtype), Accepting: true, RecvSeq: 0}
|
||||
// The AP-REP: pvno, msg-type, the encrypted part under usage twelve
|
||||
// with the echo of the timestamp and the sequence number zero.
|
||||
enc, err := Encrypt(uint32(authEtype), keyValue, UsageAPRepAuth,
|
||||
append(append(derInt(0), derInt(0)...), derInt(0)...))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
aprep := appTag(15, derTLV(tagSequence, append(append(
|
||||
derInt(5), derInt(15)...), encryptedData(uint32(authEtype), enc, 0)...)))
|
||||
return ctx, aprep, nil
|
||||
}
|
||||
|
||||
// ClientAcceptRep verifies the AP-REP the acceptor answers the
|
||||
// establishment with and completes the client half of the context: the
|
||||
// encrypted part must decrypt under the session key with the AP-REP
|
||||
// usage and carry the message type and version of RFC 4120.
|
||||
func (c *Context) ClientAcceptRep(token []byte) error {
|
||||
if c.Key == nil {
|
||||
return ErrContext
|
||||
}
|
||||
r := newDERReader(token)
|
||||
aprep, err := r.expect(0x60 | 15)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pr := newDERReader(aprep)
|
||||
seqPayload, err := pr.expect(tagSequence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sr := newDERReader(seqPayload)
|
||||
pvnoRaw, err := sr.expect(tagInteger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pvno, err := derUint(pvnoRaw)
|
||||
if err != nil || pvno != 5 {
|
||||
return ErrToken
|
||||
}
|
||||
msgTypeRaw, err := sr.expect(tagInteger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgType, err := derUint(msgTypeRaw)
|
||||
if err != nil || msgType != 15 {
|
||||
return ErrToken
|
||||
}
|
||||
encTag, encPayload, err := sr.next()
|
||||
if err != nil || encTag != tagSequence {
|
||||
return ErrDER
|
||||
}
|
||||
er := newDERReader(encPayload)
|
||||
_, epayload, eerr := er.next()
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
etype, eerr2 := derUint(epayload)
|
||||
if eerr2 != nil {
|
||||
return eerr2
|
||||
}
|
||||
if _, err = er.expect(tagInteger); err != nil { // kvno
|
||||
return err
|
||||
}
|
||||
cipher, err := er.expect(tagOctet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if uint32(etype) != c.Etype {
|
||||
return ErrContext
|
||||
}
|
||||
plain, err := Decrypt(uint32(etype), c.Key, UsageAPRepAuth, cipher)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The decrypted part answers with the client time and usec echo;
|
||||
// three integers in this profile.
|
||||
tr := newDERReader(plain)
|
||||
for range 3 {
|
||||
if _, err = tr.expect(tagInteger); err != nil {
|
||||
return ErrToken
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptMIC computes the MIC checksum body of RFC 4121 section 4.2.6.1:
|
||||
// the checksum operation over the signed data and the token header.
|
||||
func (c *Context) micSum(header, data []byte) ([]byte, error) {
|
||||
sum, err := Checksum(c.Etype, c.Key, micUsage(c.Accepting), append(append([]byte{}, data...), header...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// micUsage answers the key usage of the given side: the initiator
|
||||
// signs with seven, the acceptor with eight; a verifier always uses the
|
||||
// usage of the peer who produced the token.
|
||||
func micUsage(accepting bool) uint32 {
|
||||
if accepting {
|
||||
return UsageAcceptorSign
|
||||
}
|
||||
return UsageInitiatorSign
|
||||
}
|
||||
|
||||
// sealUsage answers the seal key usage of a side.
|
||||
func sealUsage(accepting bool) uint32 {
|
||||
if accepting {
|
||||
return UsageAcceptorSeal
|
||||
}
|
||||
return UsageInitiatorSeal
|
||||
}
|
||||
|
||||
// GetMIC produces the per-message MIC token: the fixed header with the
|
||||
// sequence number and the checksum over data and header.
|
||||
func (c *Context) GetMIC(data []byte) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
header := make([]byte, 16)
|
||||
binary.BigEndian.PutUint16(header[0:], TokenMIC)
|
||||
flags := byte(0)
|
||||
if c.Accepting {
|
||||
flags = FlagSentByAcceptor
|
||||
}
|
||||
header[2] = flags
|
||||
for i := 3; i < 8; i++ {
|
||||
header[i] = 0xff
|
||||
}
|
||||
binary.BigEndian.PutUint64(header[8:], c.SendSeq)
|
||||
c.SendSeq++
|
||||
sum, err := c.micSum(header, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(header, sum...), nil
|
||||
}
|
||||
|
||||
// VerifyMIC checks a peer MIC token against the data.
|
||||
func (c *Context) VerifyMIC(data, token []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(token) < 28 || binary.BigEndian.Uint16(token[0:]) != TokenMIC {
|
||||
return ErrToken
|
||||
}
|
||||
peerFlags := byte(0)
|
||||
if !c.Accepting {
|
||||
peerFlags = FlagSentByAcceptor
|
||||
}
|
||||
if token[2] != peerFlags {
|
||||
return ErrContext
|
||||
}
|
||||
seq := binary.BigEndian.Uint64(token[8:])
|
||||
if seq != c.RecvSeq {
|
||||
return ErrContext
|
||||
}
|
||||
c.RecvSeq++
|
||||
// The token was signed by the peer, whose signing usage differs.
|
||||
sum, err := Checksum(c.Etype, c.Key, micUsage(!c.Accepting), append(append([]byte{}, data...), token[:16]...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hmac.Equal(sum, token[16:28]) {
|
||||
return ErrIntegrity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wrap produces the confidential Wrap token: the header, then the
|
||||
// plaintext with the header appended encrypted by the profile. No
|
||||
// filler octets are needed with AES, whose cipher keeps the length.
|
||||
func (c *Context) Wrap(data []byte) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
header := make([]byte, 16)
|
||||
binary.BigEndian.PutUint16(header[0:], TokenWrap)
|
||||
flags := byte(FlagSealed)
|
||||
if c.Accepting {
|
||||
flags |= FlagSentByAcceptor
|
||||
}
|
||||
header[2] = flags
|
||||
header[3] = 0xff
|
||||
binary.BigEndian.PutUint64(header[8:], c.SendSeq)
|
||||
c.SendSeq++
|
||||
sealed, err := Encrypt(c.Etype, c.Key, sealUsage(c.Accepting), append(append([]byte{}, data...), header...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(header, sealed...), nil
|
||||
}
|
||||
|
||||
// Unwrap reverses Wrap and returns the plaintext.
|
||||
func (c *Context) Unwrap(token []byte) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(token) < 16+16+12 || binary.BigEndian.Uint16(token[0:]) != TokenWrap {
|
||||
return nil, ErrToken
|
||||
}
|
||||
peerFlags := byte(FlagSealed)
|
||||
if !c.Accepting {
|
||||
peerFlags |= FlagSentByAcceptor
|
||||
}
|
||||
if token[2]&0x03 != peerFlags&0x03 {
|
||||
return nil, ErrContext
|
||||
}
|
||||
// The Wrap token carries its own sequence number, which advances the
|
||||
// receive counter like a MIC does.
|
||||
if binary.BigEndian.Uint64(token[8:]) != c.RecvSeq {
|
||||
return nil, ErrContext
|
||||
}
|
||||
c.RecvSeq++
|
||||
// The token was sealed by the peer, whose seal usage differs.
|
||||
data, err := Decrypt(c.Etype, c.Key, sealUsage(!c.Accepting), token[16:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(data[len(data)-16:]) != string(token[:16]) {
|
||||
return nil, ErrContext
|
||||
}
|
||||
return data[:len(data)-16], nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/binary"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testKeys() []byte {
|
||||
serviceKey := make([]byte, 32)
|
||||
for i := range serviceKey {
|
||||
serviceKey[i] = byte(i)
|
||||
}
|
||||
return serviceKey
|
||||
}
|
||||
|
||||
func TestGSSExchange(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
initiator, token, err := ClientInit(EtypeAES256, serviceKey,
|
||||
"EXAMPLE.ORG", "nfs", "petr@EXAMPLE.ORG")
|
||||
if err != nil {
|
||||
t.Fatalf("client init: %v", err)
|
||||
}
|
||||
if len(token) == 0 || token[0] != 0x60 {
|
||||
t.Fatalf("context token % x", token[:8])
|
||||
}
|
||||
acceptor, reply, err := AcceptInit(token, serviceKey)
|
||||
if err != nil {
|
||||
t.Fatalf("accept init: %v", err)
|
||||
}
|
||||
if !bytes.Equal(acceptor.Key, initiator.Key) {
|
||||
t.Fatal("the session keys differ")
|
||||
}
|
||||
if len(reply) == 0 {
|
||||
t.Fatal("no AP-REP")
|
||||
}
|
||||
|
||||
// krb5i: a MIC over the message binds it to the header and the
|
||||
// sequence counters move in lock step.
|
||||
message := []byte("the rpc call header bytes")
|
||||
mic, err := initiator.GetMIC(message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mic[0] != 0x04 || mic[1] != 0x04 {
|
||||
t.Fatalf("mic token id % x", mic[:2])
|
||||
}
|
||||
if err := acceptor.VerifyMIC(message, mic); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
// The mirrored direction: the acceptor's own MIC verifies on the
|
||||
// initiator, and the sequence counters advanced.
|
||||
mic2, err := acceptor.GetMIC(message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := initiator.VerifyMIC(message, mic2); err != nil {
|
||||
t.Fatalf("verify reverse: %v", err)
|
||||
}
|
||||
// A replay of the first token is refused.
|
||||
if err := acceptor.VerifyMIC(message, mic); err != ErrContext {
|
||||
t.Fatalf("replay accepted: %v", err)
|
||||
}
|
||||
// Tampering with a fresh token is refused on the checksum.
|
||||
tampered := append([]byte{}, message...)
|
||||
tampered[0] ^= 0x80
|
||||
tamperedMIC, err := initiator.GetMIC(tampered)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tamperedMIC[20] ^= 0xff
|
||||
if err := acceptor.VerifyMIC(tampered, tamperedMIC); err != ErrIntegrity {
|
||||
t.Fatalf("tampering accepted: %v", err)
|
||||
}
|
||||
|
||||
// krb5p: the Wrap token hides and binds the payload.
|
||||
secret := []byte("the compound arguments")
|
||||
wrapped, err := initiator.Wrap(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrapped[0] != 0x05 || wrapped[1] != 0x04 {
|
||||
t.Fatalf("wrap token id % x", wrapped[:2])
|
||||
}
|
||||
if bytes.Contains(wrapped, secret) {
|
||||
t.Fatal("the plaintext leaked into the wrap token")
|
||||
}
|
||||
opened, err := acceptor.Unwrap(wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("unwrap: %v", err)
|
||||
}
|
||||
if !bytes.Equal(opened, secret) {
|
||||
t.Fatalf("unwrapped %q", opened)
|
||||
}
|
||||
wrapped[20] ^= 0xff
|
||||
if _, err := acceptor.Unwrap(wrapped); err == nil {
|
||||
t.Fatal("tampering accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// craftAPREQ builds an establishment token the way ClientInit does, with
|
||||
// the ticket endtime and the authenticator time under the caller's
|
||||
// control, so the acceptor's checks can be driven from the tests.
|
||||
func craftAPREQ(t *testing.T, endtime, ctime time.Time) []byte {
|
||||
t.Helper()
|
||||
serviceKey := testKeys()
|
||||
session := make([]byte, 32)
|
||||
for i := range session {
|
||||
session[i] = byte(255 - i)
|
||||
}
|
||||
ticket, err := buildTicket("EXAMPLE.ORG", "nfs", session, serviceKey, EtypeAES256, endtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := buildAuthenticator("EXAMPLE.ORG", "petr@EXAMPLE.ORG", session, EtypeAES256, 0, ctime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apreq := appTag(14, derTLV(tagSequence, append(append(
|
||||
derInt(5), derInt(14)...),
|
||||
append(bitFlags(0), append(ticket, auth...)...)...)))
|
||||
return appTag(0, append(append([]byte{}, derOID...), apreq...))
|
||||
}
|
||||
|
||||
func TestAcceptInitReplay(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
token := craftAPREQ(t, time.Now().Add(time.Hour), time.Now())
|
||||
if _, _, err := AcceptInit(token, serviceKey); err != nil {
|
||||
t.Fatalf("first accept: %v", err)
|
||||
}
|
||||
if _, _, err := AcceptInit(token, serviceKey); err != ErrReplay {
|
||||
t.Fatalf("replayed accept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInitSkew(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
old := craftAPREQ(t, time.Now().Add(time.Hour), time.Now().Add(-2*clockSkew))
|
||||
if _, _, err := AcceptInit(old, serviceKey); err != ErrSkew {
|
||||
t.Fatalf("an authenticator outside the window: %v", err)
|
||||
}
|
||||
future := craftAPREQ(t, time.Now().Add(time.Hour), time.Now().Add(2*clockSkew))
|
||||
if _, _, err := AcceptInit(future, serviceKey); err != ErrSkew {
|
||||
t.Fatalf("an authenticator from the future: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInitExpiredTicket(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
token := craftAPREQ(t, time.Now().Add(-time.Hour), time.Now())
|
||||
if _, _, err := AcceptInit(token, serviceKey); err != ErrExpired {
|
||||
t.Fatalf("an expired ticket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-message token operations of one context serve concurrent
|
||||
// callers without losing sequence numbers, which the race detector
|
||||
// watches over.
|
||||
func TestContextConcurrentTokens(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
initiator, token, err := ClientInit(EtypeAES256, serviceKey,
|
||||
"EXAMPLE.ORG", "nfs", "petr@EXAMPLE.ORG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acceptor, _, err := AcceptInit(token, serviceKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const workers, rounds = 4, 25
|
||||
mics := make([][]byte, workers*rounds)
|
||||
wrapped := make([][]byte, workers*rounds)
|
||||
var wg sync.WaitGroup
|
||||
for w := range workers {
|
||||
wg.Go(func() {
|
||||
for i := range rounds {
|
||||
mic, err := initiator.GetMIC([]byte("concurrent"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mics[w*rounds+i] = mic
|
||||
box, err := initiator.Wrap([]byte("secret"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
wrapped[w*rounds+i] = box
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Every sequence number was handed out exactly once, and the tokens
|
||||
// verify in order on the other side. The MIC and Wrap tokens share the
|
||||
// send counter, so the two families interleave in one space.
|
||||
type sent struct {
|
||||
seq uint64
|
||||
mic []byte
|
||||
wrap []byte
|
||||
}
|
||||
total := make([]sent, 0, 2*workers*rounds)
|
||||
for _, mic := range mics {
|
||||
total = append(total, sent{seq: binary.BigEndian.Uint64(mic[8:]), mic: mic})
|
||||
}
|
||||
for _, box := range wrapped {
|
||||
total = append(total, sent{seq: binary.BigEndian.Uint64(box[8:]), wrap: box})
|
||||
}
|
||||
slices.SortFunc(total, func(a, b sent) int { return cmp.Compare(a.seq, b.seq) })
|
||||
if len(total) != 2*workers*rounds {
|
||||
t.Fatalf("%d tokens, want %d", len(total), 2*workers*rounds)
|
||||
}
|
||||
for i, tk := range total {
|
||||
if tk.seq != uint64(i) {
|
||||
t.Fatalf("token %d carries sequence %d", i, tk.seq)
|
||||
}
|
||||
if tk.mic != nil {
|
||||
if err := acceptor.VerifyMIC([]byte("concurrent"), tk.mic); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
opened, err := acceptor.Unwrap(tk.wrap)
|
||||
if err != nil {
|
||||
t.Fatalf("unwrap: %v", err)
|
||||
}
|
||||
if string(opened) != "secret" {
|
||||
t.Fatalf("unwrapped %q", opened)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// The n-fold of RFC 3961 appendix A, ported from the reference
|
||||
// implementation of MIT krb5: the input is cycled through the least
|
||||
// common multiple of the input and output byte lengths, and the
|
||||
// repetitions summed with end-around carry.
|
||||
|
||||
package krb5
|
||||
|
||||
// NFold stretches in to outBytes octets with every input bit weighted
|
||||
// equally into every output bit.
|
||||
func NFold(in []byte, outBytes int) []byte {
|
||||
inBytes := len(in)
|
||||
|
||||
a, b := outBytes, inBytes
|
||||
for b != 0 {
|
||||
a, b = b, a%b
|
||||
}
|
||||
lcm := outBytes * inBytes / a
|
||||
|
||||
out := make([]byte, outBytes)
|
||||
carry := 0
|
||||
for i := lcm - 1; i >= 0; i-- {
|
||||
msbit := ((inBytes << 3) - 1 + ((inBytes<<3)+13)*(i/inBytes) +
|
||||
((inBytes - i%inBytes) << 3)) % (inBytes << 3)
|
||||
hi := (inBytes - 1 - (msbit >> 3)) % inBytes
|
||||
lo := (inBytes - (msbit >> 3)) % inBytes
|
||||
carry += int(((uint16(in[hi])<<8 | uint16(in[lo])) >> uint((msbit&7)+1)) & 0xff)
|
||||
carry += int(out[i%outBytes])
|
||||
out[i%outBytes] = byte(carry)
|
||||
carry >>= 8
|
||||
}
|
||||
if carry != 0 {
|
||||
for i := outBytes - 1; i >= 0; i-- {
|
||||
carry += int(out[i])
|
||||
out[i] = byte(carry)
|
||||
carry >>= 8
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user