initial
This commit is contained in:
commit
b3d7b97bd3
4 changed files with 367 additions and 0 deletions
52
README.md
Normal file
52
README.md
Normal file
|
@ -0,0 +1,52 @@
|
|||
# get_secrets
|
||||
|
||||
## Description and context
|
||||
|
||||
get_secrets is a small program to negotiate a symmetric secret between two peers authenticated by mTLS.
|
||||
|
||||
The secret is extracted using [RFC5705](https://www.rfc-editor.org/rfc/rfc5705.html) and its [TLS1.3 updated support](https://datatracker.ietf.org/doc/html/rfc8446#section-7.5).
|
||||
|
||||
This is a toy implementation, created to support the claims of episode 3 of the [Yakafokon podcast](https://pod.broken-by-design.fr/@yakafokon/).
|
||||
|
||||
## Example
|
||||
|
||||
Usage :
|
||||
|
||||
```
|
||||
-addr string
|
||||
IP address to which the TLS server must bind. By default, it binds to all local addresses
|
||||
-ca string
|
||||
Path to the file containing all of the Certification Authorities that should be trusted
|
||||
-client
|
||||
Whether we should act as a TLS client. If not specified, act as a server
|
||||
-crt string
|
||||
Path to the file containing the certificate to present to the TLS peer
|
||||
-key string
|
||||
Path to the file containing the key to use with the TLS peer
|
||||
-label string
|
||||
Label associated with the exported secret
|
||||
-length int
|
||||
Length of the exported secret, in bytes (default 32)
|
||||
-out string
|
||||
Path to the output file containing the exported secret
|
||||
-peer string
|
||||
Name of the peer that must be in the certificate returned
|
||||
-port int
|
||||
Port number of the server (default 443)
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```
|
||||
./get_secrets -ca ca/ca.pem -crt ca/server.pem -key ca/server-key.pem -out server_secret -peer client.internal -addr server.internal -port 8000 -label postgres_password
|
||||
```
|
||||
|
||||
After a client connects, and if the status code is 0, the `server_secret` file will contain a 32 bytes secret string.
|
||||
|
||||
### Client
|
||||
|
||||
```
|
||||
./get_secrets -ca ca/ca.pem -crt ca/client.pem -key ca/client-key.pem -out client_secret -peer server.internal -client -addr server.internal -port 8000 -label postgres_password
|
||||
```
|
||||
|
||||
After establishing a connection with the server, and if the status code is 0, the `client_secret` file will contain a 32 bytes secret string. `client_secret` and `server_secret` are identical.
|
7
go.mod
Normal file
7
go.mod
Normal file
|
@ -0,0 +1,7 @@
|
|||
module git.broken-by-design.fr/fmaury/get_secrets
|
||||
|
||||
go 1.23.4
|
||||
|
||||
require go.uber.org/zap v1.27.0
|
||||
|
||||
require go.uber.org/multierr v1.10.0 // indirect
|
14
go.sum
Normal file
14
go.sum
Normal file
|
@ -0,0 +1,14 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
294
main.go
Normal file
294
main.go
Normal file
|
@ -0,0 +1,294 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_CERT_POOL_SIZE = 1024
|
||||
HANDSHAKE_TICK = 10 * time.Millisecond
|
||||
HANDSHAKE_TIMEOUT = 5 * time.Second
|
||||
)
|
||||
|
||||
type config struct {
|
||||
caFile string
|
||||
certFile string
|
||||
keyFile string
|
||||
peerName string
|
||||
isClient bool
|
||||
outFile string
|
||||
serverAddress string
|
||||
serverPort int
|
||||
exportLabel string
|
||||
exportLen int
|
||||
}
|
||||
|
||||
func parseArgs() (c config, err error) {
|
||||
caFile := flag.String("ca", "", "Path to the file containing all of the Certification Authorities that should be trusted")
|
||||
certFile := flag.String("crt", "", "Path to the file containing the certificate to present to the TLS peer")
|
||||
keyFile := flag.String("key", "", "Path to the file containing the key to use with the TLS peer")
|
||||
peerName := flag.String("peer", "", "Name of the peer that must be in the certificate returned")
|
||||
isClient := flag.Bool("client", false, "Whether we should act as a TLS client. If not specified, act as a server")
|
||||
outFile := flag.String("out", "", "Path to the output file containing the exported secret")
|
||||
exportLabel := flag.String("label", "", "Label associated with the exported secret")
|
||||
exportLen := flag.Int("length", 32, "Length of the exported secret, in bytes")
|
||||
serverAddress := flag.String("addr", "", "IP address to which the TLS server must bind. By default, it binds to all local addresses")
|
||||
serverPort := flag.Int("port", 443, "Port number of the server")
|
||||
flag.Parse()
|
||||
|
||||
if *caFile == "" {
|
||||
err = fmt.Errorf("ca argument is mandatory")
|
||||
return
|
||||
}
|
||||
if *certFile == "" {
|
||||
err = fmt.Errorf("crt argument is mandatory")
|
||||
return
|
||||
}
|
||||
if *keyFile == "" {
|
||||
err = fmt.Errorf("key argument is mandatory")
|
||||
return
|
||||
}
|
||||
if *peerName == "" {
|
||||
err = fmt.Errorf("peer argument is mandatory")
|
||||
return
|
||||
}
|
||||
if *outFile == "" {
|
||||
err = fmt.Errorf("out argument is mandatory")
|
||||
return
|
||||
}
|
||||
if *exportLabel == "" {
|
||||
err = fmt.Errorf("label argument is mandatory")
|
||||
}
|
||||
c = config{
|
||||
caFile: *caFile,
|
||||
certFile: *certFile,
|
||||
keyFile: *keyFile,
|
||||
peerName: *peerName,
|
||||
isClient: *isClient,
|
||||
outFile: *outFile,
|
||||
exportLabel: *exportLabel,
|
||||
exportLen: *exportLen,
|
||||
serverAddress: *serverAddress,
|
||||
serverPort: *serverPort,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getCAPool(caFile string) (cp *x509.CertPool, retErr error) {
|
||||
cp = x509.NewCertPool()
|
||||
f, err := os.Open(caFile)
|
||||
if err != nil {
|
||||
retErr = fmt.Errorf("failed to open CA file: %w", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fileInfo, err := f.Stat()
|
||||
if err != nil {
|
||||
retErr = fmt.Errorf("failed to stat() CA file: %w", err)
|
||||
return
|
||||
}
|
||||
fileMap, err := syscall.Mmap(int(f.Fd()), 0, int(fileInfo.Size()), syscall.PROT_READ, syscall.MAP_PRIVATE)
|
||||
if err != nil {
|
||||
retErr = fmt.Errorf("failed to mmap file content: %w", err)
|
||||
return
|
||||
}
|
||||
defer syscall.Munmap(fileMap)
|
||||
|
||||
certPoolSize := 0
|
||||
fm := fileMap[:]
|
||||
for len(fm) > 0 {
|
||||
block, rem := pem.Decode(fm)
|
||||
if block == nil {
|
||||
max := 1024
|
||||
if len(fm) < max {
|
||||
max = len(fm)
|
||||
}
|
||||
retErr = fmt.Errorf("invalid PEM encoding at %q", string(fm[:max]))
|
||||
return
|
||||
}
|
||||
fm = rem
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
retErr = fmt.Errorf("failed to parse certificate: %w", err)
|
||||
return
|
||||
}
|
||||
cp.AddCert(cert)
|
||||
certPoolSize += 1
|
||||
if certPoolSize > MAX_CERT_POOL_SIZE {
|
||||
retErr = fmt.Errorf("cert pool max size exceeded")
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func verifyClientCertificate(verifiedChains [][]*x509.Certificate, peerName string) error {
|
||||
for _, certChain := range verifiedChains {
|
||||
if len(certChain) == 0 {
|
||||
return fmt.Errorf("empty cert chain: should never happen?")
|
||||
}
|
||||
cert := *certChain[0]
|
||||
if !slices.Contains(cert.DNSNames, peerName) {
|
||||
continue
|
||||
}
|
||||
if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth) {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to found a proper certificate matching the expected values")
|
||||
}
|
||||
|
||||
func getTLSConf(generalConf config) (tc tls.Config, retErr error) {
|
||||
cert, err := tls.LoadX509KeyPair(generalConf.certFile, generalConf.keyFile)
|
||||
if err != nil {
|
||||
retErr = fmt.Errorf("failed to load certificates or private key from files: %w", err)
|
||||
return
|
||||
}
|
||||
caPool, err := getCAPool(generalConf.caFile)
|
||||
if err != nil {
|
||||
retErr = fmt.Errorf("failed to load CA certificates: %w", err)
|
||||
return
|
||||
}
|
||||
tc.MinVersion = tls.VersionTLS13
|
||||
tc.Certificates = []tls.Certificate{cert}
|
||||
|
||||
if generalConf.isClient { // TLS Client
|
||||
tc.RootCAs = caPool
|
||||
tc.ServerName = generalConf.peerName
|
||||
} else { // TLS Server
|
||||
tc.ClientCAs = caPool
|
||||
tc.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
tc.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
|
||||
return verifyClientCertificate(verifiedChains, generalConf.peerName)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func handshakeCompleted(ctx context.Context, conn *tls.Conn) <-chan struct{} {
|
||||
c := make(chan struct{}, 0)
|
||||
go func() {
|
||||
tick := time.NewTicker(HANDSHAKE_TICK)
|
||||
for {
|
||||
cs := conn.ConnectionState()
|
||||
if cs.HandshakeComplete {
|
||||
c <- struct{}{}
|
||||
return
|
||||
}
|
||||
tick.Reset(HANDSHAKE_TICK)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
func getClientSecret(generalConf config, tlsConfig *tls.Config) ([]byte, error) {
|
||||
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", generalConf.serverAddress, generalConf.serverPort), tlsConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to the TLS server: %w", err)
|
||||
}
|
||||
if err := conn.Handshake(); err != nil {
|
||||
return nil, fmt.Errorf("failed to perform handshake: %w", err)
|
||||
}
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), HANDSHAKE_TIMEOUT)
|
||||
defer cancelFunc()
|
||||
select {
|
||||
case <-handshakeCompleted(ctx, conn):
|
||||
cancelFunc()
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("failed to perform handshake during the alloted time")
|
||||
}
|
||||
defer conn.Close()
|
||||
cs := conn.ConnectionState()
|
||||
keyMat, err := cs.ExportKeyingMaterial(generalConf.exportLabel, nil, generalConf.exportLen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to export key material from TLS connection: %w", err)
|
||||
}
|
||||
return keyMat, nil
|
||||
}
|
||||
|
||||
func getServerSecret(generalConf config, tlsConfig *tls.Config) ([]byte, error) {
|
||||
lstn, err := net.Listen("tcp", fmt.Sprintf("%s:%d", generalConf.serverAddress, generalConf.serverPort))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create the new server listener: %w", err)
|
||||
}
|
||||
conn, err := lstn.Accept()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to accept connection from the client: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
tlsConn := tls.Server(conn, tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return nil, fmt.Errorf("failed to perform handshake: %w", err)
|
||||
}
|
||||
defer tlsConn.Close()
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), HANDSHAKE_TIMEOUT)
|
||||
defer cancelFunc()
|
||||
select {
|
||||
case <-handshakeCompleted(ctx, tlsConn):
|
||||
cancelFunc()
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("failed to perform handshake during the alloted time")
|
||||
}
|
||||
|
||||
cs := tlsConn.ConnectionState()
|
||||
keyMat, _ := cs.ExportKeyingMaterial(generalConf.exportLabel, nil, generalConf.exportLen)
|
||||
return keyMat, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
rawLogger, err := zap.NewProduction()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer rawLogger.Sync()
|
||||
logger := rawLogger.Sugar()
|
||||
|
||||
generalConf, err := parseArgs()
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to parse args: %s", err.Error())
|
||||
}
|
||||
|
||||
tlsConfig, err := getTLSConf(generalConf)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to initialize TLS config: %s", err.Error())
|
||||
}
|
||||
|
||||
var secret []byte
|
||||
if generalConf.isClient {
|
||||
secret, err = getClientSecret(generalConf, &tlsConfig)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to get secret: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
secret, err = getServerSecret(generalConf, &tlsConfig)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to get secret: %s", err.Error())
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(generalConf.outFile, secret, 0o600); err != nil {
|
||||
logger.Fatalf("failed to write to file exported secret: %s", err.Error())
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue