Files
tailscale/feature/conn25/token.go
Fran Bull 0a88fa1ec6 feature/conn25: mint/verify TransitIPRequest tokens
Add functions to allow minting and verifying the tokens.

Updates tailscale/corp#46169

Signed-off-by: Fran Bull <fran@tailscale.com>
2026-11-02 13:23:31 -08:00

95 lines
2.9 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conn25
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"fmt"
"math"
"net/netip"
)
// tokenSecretLen is the length in bytes of the connector's token secret. It is
// the recommended key size for HMAC-SHA-256, which is used to mint and verify
// destination IP tokens.
const tokenSecretLen = 32
// tokenVersion1 is the version byte of the first token format.
const tokenVersion1 = 1
// tokenMACLen is the number of bytes of HMAC-SHA-256 output kept in a token. The
// full 32-byte MAC is truncated to save bytes on the wire.
const tokenMACLen = 16
// newTokenSecret returns a fresh random token secret.
func newTokenSecret() [tokenSecretLen]byte {
var secret [tokenSecretLen]byte
rand.Read(secret[:])
return secret
}
// mintToken returns an opaque token authenticating that this connector served
// dst in a DNS answer for app. The client stores it and echoes it back
// in a later TransitIPRequest, which this connector verifies by recomputing the
// MAC over the same inputs.
func (c *connector) mintToken(app string, dst netip.Addr) ([]byte, error) {
if len(app) > math.MaxUint16 {
return nil, fmt.Errorf("conn25: app name too long to mint token: %d bytes", len(app))
}
c.mu.Lock()
secret := c.tokenSecret
c.mu.Unlock()
token := make([]byte, 0, 1+tokenMACLen)
token = append(token, tokenVersion1)
token = append(token, tokenMAC(secret, app, dst)...)
return token, nil
}
// verifyToken reports whether token is a valid [tokenVersion1] token minted by
// this connector for exactly app, and dst.
func (c *connector) verifyToken(token []byte, app string, dst netip.Addr) bool {
if len(token) != 1+tokenMACLen || token[0] != tokenVersion1 {
return false
}
if len(app) > math.MaxUint16 {
return false
}
c.mu.Lock()
secret := c.tokenSecret
c.mu.Unlock()
return hmac.Equal(token[1:], tokenMAC(secret, app, dst))
}
// tokenMAC returns the truncated HMAC-SHA-256 that authenticates a token's
// inputs.
//
// The token is version(1) | mac(16), where mac is
//
// HMAC-SHA-256(tokenSecret, version | uint16(len(app)) | app | dst)
//
// truncated to [tokenMACLen] bytes. dst is canonicalized to its 16-byte form so
// that the IPv4 and IPv4-in-IPv6 representations of the same address produce the
// same token. app is length-prefixed because it is the only variable-width
// input, which keeps the concatenation unambiguous. An app name longer than the
// uint16 length prefix can hold is rejected rather than minted, since a
// truncated prefix would reintroduce that ambiguity.
func tokenMAC(secret [tokenSecretLen]byte, app string, dst netip.Addr) []byte {
mac := hmac.New(sha256.New, secret[:])
mac.Write([]byte{tokenVersion1})
var appLen [2]byte
binary.BigEndian.PutUint16(appLen[:], uint16(len(app)))
mac.Write(appLen[:])
mac.Write([]byte(app))
dstCanon := dst.Unmap().As16()
mac.Write(dstCanon[:])
return mac.Sum(nil)[:tokenMACLen]
}