feature: Initial commit.

This commit is contained in:
greysoh 2024-10-19 14:49:24 -04:00
commit c6c407d205
Signed by: imterah
GPG key ID: 8FA7DD57BA6CEA37
12 changed files with 1402 additions and 0 deletions

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Bismuth Protocol
The Bismuth protocol is a thin wrapper for any protocol that adds TLS-like features, without being TLS on its own.

6
ascii.txt Normal file
View file

@ -0,0 +1,6 @@
___. .__ __ .__ .___
\_ |__ |__| ______ _____ __ ___/ |_| |__ __| _/
| __ \| |/ ___// \| | \ __\ | \ / __ |
| \_\ \ |\___ \| Y Y \ | /| | | Y \/ /_/ |
|___ /__/____ >__|_| /____/ |__| |___| /\____ |
\/ \/ \/ \/ \/

208
client/client.go Normal file
View file

@ -0,0 +1,208 @@
package client
import (
"crypto/cipher"
"crypto/rand"
"fmt"
"net"
core "git.greysoh.dev/imterah/bismuthd/commons"
"golang.org/x/crypto/chacha20poly1305"
"github.com/ProtonMail/gopenpgp/v3/crypto"
)
func (bismuth BismuthClient) encryptMessage(aead cipher.AEAD, msg []byte) ([]byte, error) {
nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(msg)+aead.Overhead())
if _, err := rand.Read(nonce); err != nil {
return []byte{}, err
}
encryptedMsg := aead.Seal(nonce, nonce, msg, nil)
return encryptedMsg, nil
}
func (bismuth BismuthClient) decryptMessage(aead cipher.AEAD, encMsg []byte) ([]byte, error) {
if len(encMsg) < aead.NonceSize() {
return []byte{}, fmt.Errorf("ciphertext too short")
}
// Split nonce and ciphertext.
nonce, ciphertext := encMsg[:aead.NonceSize()], encMsg[aead.NonceSize():]
// Decrypt the message and check it wasn't tampered with.
decryptedData, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return []byte{}, err
}
return decryptedData, nil
}
type BismuthClient struct {
PublicKey *crypto.Key
PrivateKey *crypto.Key
pgp *crypto.PGPHandle
}
func (bismuth BismuthClient) Conn(conn net.Conn) (net.Conn, error) {
// Yes, I'm aware defer exists. It won't work if I use it in this context. I'll shank anyone that complains
// Exchange our public keys first
ownKey, err := bismuth.PublicKey.GetPublicKey()
if err != nil {
conn.Close()
return nil, err
}
pubKeyLengthBytes := make([]byte, 3)
pubKeyLength := uint32(len(ownKey))
core.Int32ToInt24(pubKeyLengthBytes, pubKeyLength)
conn.Write([]byte{core.SendPublicKey})
conn.Write(pubKeyLengthBytes)
conn.Write(ownKey)
messageMode := make([]byte, 1)
if _, err = conn.Read(messageMode); err != nil {
conn.Close()
return nil, err
}
if messageMode[0] != core.SendPublicKey {
conn.Close()
return nil, fmt.Errorf("server failed to return its public key")
}
if _, err = conn.Read(pubKeyLengthBytes); err != nil {
conn.Close()
return nil, err
}
pubKeyLength = core.Int24ToInt32(pubKeyLengthBytes)
pubKeyBytes := make([]byte, pubKeyLength)
if _, err = conn.Read(pubKeyBytes); err != nil {
conn.Close()
return nil, err
}
if _, err = crypto.NewKey(pubKeyBytes); err != nil {
conn.Close()
return nil, err
}
// Then exchange the symmetric key
conn.Write([]byte{core.SwitchToSymmetricKey})
if _, err = conn.Read(messageMode); err != nil {
conn.Close()
return nil, err
}
if messageMode[0] != core.SwitchToSymmetricKey {
conn.Close()
return nil, fmt.Errorf("server failed to return symmetric key")
}
encryptedSymmKeyLengthInBytes := make([]byte, 3)
if _, err = conn.Read(encryptedSymmKeyLengthInBytes); err != nil {
conn.Close()
return nil, err
}
encryptedSymmKeyLength := core.Int24ToInt32(encryptedSymmKeyLengthInBytes)
encryptedSymmKey := make([]byte, encryptedSymmKeyLength)
if _, err = conn.Read(encryptedSymmKey); err != nil {
conn.Close()
return nil, err
}
decHandleForSymmKey, err := bismuth.pgp.Decryption().DecryptionKey(bismuth.PrivateKey).New()
if err != nil {
return nil, err
}
decryptedSymmKey, err := decHandleForSymmKey.Decrypt(encryptedSymmKey, crypto.Bytes)
if err != nil {
return nil, err
}
symmKeyInfo := decryptedSymmKey.Bytes()
if symmKeyInfo[0] != core.XChaCha20Poly1305 {
conn.Close()
return nil, fmt.Errorf("unsupported encryption method recieved")
}
symmKey := symmKeyInfo[1 : chacha20poly1305.KeySize+1]
aead, err := chacha20poly1305.NewX(symmKey)
// Start proxying
startForwardingPacket := []byte{
core.InitiateForwarding,
}
encryptedForwardPacket, err := bismuth.encryptMessage(aead, startForwardingPacket)
if err != nil {
conn.Close()
return nil, err
}
encryptedForwardPacketPacketSize := make([]byte, 3)
core.Int32ToInt24(encryptedForwardPacketPacketSize, uint32(len(encryptedForwardPacket)))
conn.Write(encryptedForwardPacketPacketSize)
conn.Write(encryptedForwardPacket)
_, err = bismuth.decryptMessage(aead, encryptedForwardPacket)
bmConn := core.BismuthConn{
Aead: aead,
PassedConn: conn,
MaxBufSize: core.ConnStandardMaxBufSize,
}
bmConn.DoInitSteps()
return core.BismuthConnWrapped{
Bismuth: &bmConn,
}, nil
}
func New(pubKey string, privKey string) (*BismuthClient, error) {
publicKey, err := crypto.NewKeyFromArmored(pubKey)
if err != nil {
return nil, err
}
privateKey, err := crypto.NewKeyFromArmored(privKey)
if err != nil {
return nil, err
}
pgp := crypto.PGP()
bismuth := BismuthClient{
PublicKey: publicKey,
PrivateKey: privateKey,
pgp: pgp,
}
return &bismuth, nil
}

290
commons/conn.go Normal file
View file

@ -0,0 +1,290 @@
package commons
import (
"crypto/cipher"
"crypto/rand"
"fmt"
"net"
"sync"
"time"
)
// Give it 2x the max size for a TCP packet
var ConnStandardMaxBufSize = (65535 * 2)
var CryptHeader = 43
// Wild
type BismuthConn struct {
Aead cipher.AEAD
PassedConn net.Conn
lock *sync.Mutex
initDone bool
contentBuf []byte
contentBufPos int
contentBufSize int
MaxBufSize int
AllowNonstandardPacketSizeLimit bool
net.Conn
}
func (bmConn *BismuthConn) DoInitSteps() {
bmConn.lock = &sync.Mutex{}
bmConn.contentBuf = make([]byte, bmConn.MaxBufSize)
bmConn.initDone = true
}
func (bmConn *BismuthConn) encryptMessage(msg []byte) ([]byte, error) {
nonce := make([]byte, bmConn.Aead.NonceSize(), bmConn.Aead.NonceSize()+len(msg)+bmConn.Aead.Overhead())
if _, err := rand.Read(nonce); err != nil {
return []byte{}, err
}
encryptedMsg := bmConn.Aead.Seal(nonce, nonce, msg, nil)
return encryptedMsg, nil
}
func (bmConn *BismuthConn) decryptMessage(encMsg []byte) ([]byte, error) {
if len(encMsg) < bmConn.Aead.NonceSize() {
return []byte{}, fmt.Errorf("ciphertext too short")
}
// Split nonce and ciphertext.
nonce, ciphertext := encMsg[:bmConn.Aead.NonceSize()], encMsg[bmConn.Aead.NonceSize():]
// Decrypt the message and check it wasn't tampered with.
decryptedData, err := bmConn.Aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return []byte{}, err
}
return decryptedData, nil
}
func (bmConn *BismuthConn) ResizeContentBuf() error {
if !bmConn.initDone {
return fmt.Errorf("bmConn not initialized")
}
bmConn.lock.Lock()
if bmConn.contentBufSize != 0 {
// TODO: switch this to do append() instead, when I finally decide to consider this "optimization" in the main buffer logic
// This code below basically, instead of growing it, gets the actually unused cache data, then grows it and copies it over.
//
// This is probably unneccesary, but it saves some hassle I guess.
currentContentBufData := bmConn.contentBuf[bmConn.contentBufPos:bmConn.contentBufSize]
bmConn.contentBufSize = bmConn.contentBufSize - bmConn.contentBufPos
bmConn.contentBufPos = 0
bmConn.contentBuf = make([]byte, bmConn.MaxBufSize)
copy(bmConn.contentBuf[len(currentContentBufData):], currentContentBufData)
} else {
bmConn.contentBuf = make([]byte, bmConn.MaxBufSize)
}
bmConn.lock.Unlock()
return nil
}
func (bmConn *BismuthConn) ReadFromBuffer(b []byte) (n int, err error) {
bmConn.lock.Lock()
defer bmConn.lock.Unlock()
calcContentBufSize := bmConn.contentBufSize - bmConn.contentBufPos
providedBufferSize := len(b)
if bmConn.contentBufSize == 0 {
return 0, nil
}
if calcContentBufSize <= providedBufferSize {
copy(b, bmConn.contentBuf[bmConn.contentBufPos:bmConn.contentBufSize])
bmConn.contentBufPos = 0
bmConn.contentBufSize = 0
bmConn.contentBuf = make([]byte, bmConn.MaxBufSize)
return calcContentBufSize, nil
} else if calcContentBufSize > providedBufferSize {
newContentBufSize := bmConn.contentBufPos + providedBufferSize
copy(b, bmConn.contentBuf[bmConn.contentBufPos:newContentBufSize])
bmConn.contentBufPos = newContentBufSize
return providedBufferSize, nil
}
return 0, nil
}
func (bmConn *BismuthConn) ReadFromNetwork(b []byte) (n int, err error) {
bmConn.lock.Lock()
defer bmConn.lock.Unlock()
bufferSize := len(b)
encryptedContentLengthBytes := make([]byte, 3)
if _, err = bmConn.PassedConn.Read(encryptedContentLengthBytes); err != nil {
return 0, err
}
encryptedContentLength := Int24ToInt32(encryptedContentLengthBytes)
encryptedContent := make([]byte, encryptedContentLength)
// Check to see if we can fit the packet inside either:
// - the max TCP packet size (64k) if 'AllowNonstandardPacketSizeLimit' isn't set; or
// - the max buffer size if 'AllowNonstandardPacketSizeLimit' is set
// We check AFTER we read to make sure that we don't corrupt any future packets, because if we don't read the packet,
// it will think that the actual packet will be the start of the packet, and that would cause loads of problems.
if !bmConn.AllowNonstandardPacketSizeLimit && encryptedContentLength > uint32(65535+CryptHeader) {
return 0, fmt.Errorf("packet too large")
} else if bmConn.AllowNonstandardPacketSizeLimit && encryptedContentLength > uint32(bmConn.MaxBufSize) {
return 0, fmt.Errorf("packet too large")
}
totalPosition := 0
for totalPosition != int(encryptedContentLength) {
currentPosition, err := bmConn.PassedConn.Read(encryptedContent[totalPosition:encryptedContentLength])
totalPosition += currentPosition
if err != nil {
return 0, err
}
}
decryptedContent, err := bmConn.decryptMessage(encryptedContent)
decryptedContentSize := len(decryptedContent)
calcSize := min(decryptedContentSize, bufferSize)
copy(b[:calcSize], decryptedContent)
if bufferSize < int(decryptedContentSize) {
newSlice := decryptedContent[calcSize:]
if bmConn.contentBufSize+len(newSlice) > bmConn.MaxBufSize {
return 0, fmt.Errorf("ran out of room in the buffer to store data! (can't overflow the buffer...)")
}
copy(bmConn.contentBuf[bmConn.contentBufSize:bmConn.contentBufSize+len(newSlice)], newSlice)
bmConn.contentBufSize += len(newSlice)
}
if err != nil {
return calcSize, err
}
return calcSize, nil
}
func (bmConn *BismuthConn) Read(b []byte) (n int, err error) {
if !bmConn.initDone {
return 0, fmt.Errorf("bmConn not initialized")
}
bufferReadSize, err := bmConn.ReadFromBuffer(b)
if err != nil {
return bufferReadSize, err
}
if bufferReadSize == len(b) {
return bufferReadSize, nil
}
networkReadSize, err := bmConn.ReadFromNetwork(b[bufferReadSize:])
if err != nil {
return bufferReadSize + networkReadSize, err
}
return bufferReadSize + networkReadSize, nil
}
func (bmConn *BismuthConn) Write(b []byte) (n int, err error) {
encryptedMessage, err := bmConn.encryptMessage(b)
if err != nil {
return 0, err
}
encryptedMessageSize := make([]byte, 3)
Int32ToInt24(encryptedMessageSize, uint32(len(encryptedMessage)))
bmConn.PassedConn.Write(encryptedMessageSize)
bmConn.PassedConn.Write(encryptedMessage)
return len(b), nil
}
func (bmConn *BismuthConn) Close() error {
return bmConn.PassedConn.Close()
}
func (bmConn *BismuthConn) LocalAddr() net.Addr {
return bmConn.PassedConn.LocalAddr()
}
func (bmConn *BismuthConn) RemoteAddr() net.Addr {
return bmConn.PassedConn.RemoteAddr()
}
func (bmConn *BismuthConn) SetDeadline(time time.Time) error {
return bmConn.PassedConn.SetDeadline(time)
}
func (bmConn *BismuthConn) SetReadDeadline(time time.Time) error {
return bmConn.PassedConn.SetReadDeadline(time)
}
func (bmConn *BismuthConn) SetWriteDeadline(time time.Time) error {
return bmConn.PassedConn.SetWriteDeadline(time)
}
// TODO: remove this ugly hack if possible! There's probably a better way around this...
type BismuthConnWrapped struct {
Bismuth *BismuthConn
}
func (bmConn BismuthConnWrapped) Read(b []byte) (n int, err error) {
return bmConn.Bismuth.Read(b)
}
func (bmConn BismuthConnWrapped) Write(b []byte) (n int, err error) {
return bmConn.Bismuth.Write(b)
}
func (bmConn BismuthConnWrapped) Close() error {
return bmConn.Bismuth.Close()
}
func (bmConn BismuthConnWrapped) LocalAddr() net.Addr {
return bmConn.Bismuth.LocalAddr()
}
func (bmConn BismuthConnWrapped) RemoteAddr() net.Addr {
return bmConn.Bismuth.RemoteAddr()
}
func (bmConn BismuthConnWrapped) SetDeadline(time time.Time) error {
return bmConn.Bismuth.SetDeadline(time)
}
func (bmConn BismuthConnWrapped) SetReadDeadline(time time.Time) error {
return bmConn.Bismuth.SetReadDeadline(time)
}
func (bmConn BismuthConnWrapped) SetWriteDeadline(time time.Time) error {
return bmConn.Bismuth.SetWriteDeadline(time)
}

11
commons/conv.go Normal file
View file

@ -0,0 +1,11 @@
package commons
func Int24ToInt32(b []byte) uint32 {
return uint32(b[2]) | uint32(b[1])<<8 | uint32(b[0])<<16
}
func Int32ToInt24(b []byte, int uint32) {
b[0] = uint8((int >> 16) & 0xff)
b[1] = uint8((int >> 8) & 0xff)
b[2] = uint8(int & 0xff)
}

19
commons/enum.go Normal file
View file

@ -0,0 +1,19 @@
package commons
const (
SendPublicKey = iota
SwitchToSymmetricKey
ClientSendHost
GetSigningServers
GetTrustedDomains
InitiateForwarding
)
const (
XChaCha20Poly1305 = iota
)
const (
BitLimit24 = 16_777_215
BitLimit16 = 65535
)

33
go.mod Normal file
View file

@ -0,0 +1,33 @@
module git.greysoh.dev/imterah/bismuthd
go 1.23.0
require (
github.com/ProtonMail/gopenpgp/v3 v3.0.0-beta.1-proton
github.com/charmbracelet/log v0.4.0
github.com/urfave/cli/v2 v2.27.4
tailscale.com v1.74.1
)
require (
github.com/ProtonMail/go-crypto v1.1.0-beta.0-proton // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.3.2 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 // indirect
golang.org/x/sys v0.26.0 // indirect
)

72
go.sum Normal file
View file

@ -0,0 +1,72 @@
github.com/ProtonMail/go-crypto v1.1.0-beta.0-proton h1:ZGewsAoeSirbUS5cO8L0FMQA+iSop9xR1nmFYifDBPo=
github.com/ProtonMail/go-crypto v1.1.0-beta.0-proton/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/gopenpgp/v3 v3.0.0-beta.1-proton h1:WwNSUldLJ2Di+gvRK8ePCsY1HEgieVGyWhD90MGaSak=
github.com/ProtonMail/gopenpgp/v3 v3.0.0-beta.1-proton/go.mod h1:TBpqWZ9IzA7g3TEzNA9Fwv/nA/eYpjcvYQBq+FX+tE4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY=
github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 h1:xcuWappghOVI8iNWoF2OKahVejd1LSVi/v4JED44Amo=
github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 h1:1wqE9dj9NpSm04INVsJhhEUzhuDVjbcyKH91sVyPATw=
golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tailscale.com v1.74.1 h1:qhhkN+0gFZasczi+0n0eBxwfP/ZaUr+05cWdsOQ3GT0=
tailscale.com v1.74.1/go.mod h1:3iACpCONQ4lauDXvwfoGlwNCpfbVxjdc2j6G9EuFOW8=

331
main.go Normal file
View file

@ -0,0 +1,331 @@
package main
import (
"context"
_ "embed"
"fmt"
"net"
"os"
"strings"
"git.greysoh.dev/imterah/bismuthd/client"
core "git.greysoh.dev/imterah/bismuthd/commons"
"git.greysoh.dev/imterah/bismuthd/server"
"github.com/charmbracelet/log"
"github.com/urfave/cli/v2"
"tailscale.com/net/socks5"
)
//go:embed ascii.txt
var asciiArt string
func bismuthClientEntrypoint(cCtx *cli.Context) error {
pubKeyFile, err := os.ReadFile(cCtx.String("pubkey"))
if err != nil {
return err
}
privKeyFile, err := os.ReadFile(cCtx.String("privkey"))
if err != nil {
return err
}
pubKey := string(pubKeyFile)
privKey := string(privKeyFile)
bismuth, err := client.New(pubKey, privKey)
if err != nil {
return err
}
routeAllTraffic := cCtx.Bool("route-all-traffic")
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%s", cCtx.String("ip"), cCtx.String("port")))
if err != nil {
return err
}
socksServer := socks5.Server{
Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) {
ip := addr[:strings.Index(addr, ":")]
isBismuthTLD := strings.HasSuffix(ip, ".bismuth")
// There isn't a good way to make the rest of the internet compatible and also have a seperate protocol,
// so we just do this.
if isBismuthTLD || routeAllTraffic {
log.Debugf("Recieved bismuth connection to '%s'. Routing", addr)
address := addr
ip := ip
port := ""
if isBismuthTLD {
ip = ip[:strings.LastIndex(addr, ".")]
port := addr[strings.Index(addr, ":")+1:]
address = ip + ":" + port
} else {
ip = ip[:strings.LastIndex(addr, ":")]
port := addr[strings.Index(addr, ":")+1:]
address = ip + ":" + port
}
conn, err := net.Dial(network, address)
if err != nil && err.Error() != "EOF" {
log.Errorf("TCP connection to '%s:%s' failed", ip, port)
return nil, err
}
conn, err = bismuth.Conn(conn)
if err != nil && err.Error() != "EOF" {
log.Errorf("failed to initialize bismuth connection to '%s:%s': '%s'", ip, port, err.Error())
return nil, err
}
return conn, err
} else {
conn, err := net.Dial(network, addr)
if err != nil && err.Error() != "EOF" {
log.Errorf("TCP connection to '%s' failed", addr)
return nil, err
}
return conn, err
}
},
}
log.Info("Bismuth client is listening...")
socksServer.Serve(listener)
return nil
}
func bismuthServerEntrypoint(cCtx *cli.Context) error {
relayServers := []string{}
pubKeyFile, err := os.ReadFile(cCtx.String("pubkey"))
if err != nil {
return err
}
privKeyFile, err := os.ReadFile(cCtx.String("privkey"))
if err != nil {
return err
}
pubKey := string(pubKeyFile)
privKey := string(privKeyFile)
network := fmt.Sprintf("%s:%s", cCtx.String("source-ip"), cCtx.String("source-port"))
bismuth, err := server.NewBismuthServer(pubKey, privKey, relayServers, core.XChaCha20Poly1305, func(connBismuth net.Conn) error {
connDialed, err := net.Dial("tcp", network)
if err != nil {
return err
}
bismuthBuffer := make([]byte, 65535)
dialBuffer := make([]byte, 65535)
go func() {
defer connDialed.Close()
defer connBismuth.Close()
for {
len, err := connBismuth.Read(bismuthBuffer)
if err != nil {
log.Errorf("failed to read from bismuth server: '%s'", err.Error())
return
}
_, err = connDialed.Write(bismuthBuffer[:len])
if err != nil {
log.Errorf("failed to write to the proxied server: '%s'", err.Error())
return
}
}
}()
go func() {
defer connDialed.Close()
defer connBismuth.Close()
for {
len, err := connDialed.Read(dialBuffer)
if err != nil && err.Error() != "EOF" && strings.HasSuffix(err.Error(), "use of closed network connection") {
log.Errorf("failed to read from proxied server: '%s'", err.Error())
return
}
_, err = connBismuth.Write(dialBuffer[:len])
if err != nil && err.Error() != "EOF" && strings.HasSuffix(err.Error(), "use of closed network connection") {
log.Errorf("failed to write to bismuth server: '%s'", err.Error())
return
}
}
}()
return nil
})
if err != nil {
return err
}
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%s", cCtx.String("dest-ip"), cCtx.String("dest-port")))
if err != nil {
return err
}
defer listener.Close()
log.Info("Bismuth server is listening...")
for {
conn, err := listener.Accept()
log.Debugf("Recieved connection from '%s'", conn.RemoteAddr().String())
if err != nil {
log.Warn(err.Error())
continue
}
go func() {
err := bismuth.HandleProxy(conn)
if err != nil && err.Error() != "EOF" {
log.Warnf("Connection crashed/dropped during proxy handling: '%s'", err.Error())
}
}()
}
}
func main() {
fmt.Println(asciiArt)
fmt.Print("Implementation of the Bismuth protocol\n\n")
logLevel := os.Getenv("BISMUTHD_LOG_LEVEL")
if logLevel != "" {
switch logLevel {
case "debug":
log.SetLevel(log.DebugLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "warn":
log.SetLevel(log.WarnLevel)
case "error":
log.SetLevel(log.ErrorLevel)
case "fatal":
log.SetLevel(log.FatalLevel)
}
}
app := &cli.App{
Name: "bismuthd",
Usage: "reference implementation of the bismuth protocol",
EnableBashCompletion: true,
Commands: []*cli.Command{
{
Name: "client",
Aliases: []string{"c"},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "pubkey",
Usage: "path to PGP public key",
Required: true,
},
&cli.StringFlag{
Name: "privkey",
Usage: "path to PGP private key",
Required: true,
},
&cli.StringFlag{
Name: "ip",
Usage: "IP to listen on for SOCKS5 server",
Value: "127.0.0.1",
},
&cli.StringFlag{
Name: "port",
Usage: "port to listen on for SOCKS5 server",
Value: "1080",
},
&cli.BoolFlag{
Name: "disable-gui",
Usage: "if set, disables the GUI and automatically accepts all certificates (not recommended)",
},
&cli.BoolFlag{
Name: "route-all-traffic",
Usage: "if set, routes all traffic through Bismuth, instead of just IPs that end with the fictional TLD '.bismuth'",
},
},
Usage: "client for the Bismuth protocol",
Action: bismuthClientEntrypoint,
},
{
Name: "server",
Aliases: []string{"s"},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "pubkey",
Usage: "path to PGP public key",
Required: true,
},
&cli.StringFlag{
Name: "privkey",
Usage: "path to PGP private key",
Required: true,
},
&cli.StringFlag{
Name: "source-ip",
Usage: "IP to connect to",
Required: true,
},
&cli.StringFlag{
Name: "source-port",
Usage: "port to connect to",
Required: true,
},
&cli.StringFlag{
Name: "dest-ip",
Usage: "IP to listen on",
Value: "0.0.0.0",
},
&cli.StringFlag{
Name: "dest-port",
Usage: "port to listen on",
Required: true,
},
},
Usage: "server for the Bismuth protocol",
Action: bismuthServerEntrypoint,
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

257
server/server.go Normal file
View file

@ -0,0 +1,257 @@
package server
import (
"fmt"
"net"
core "git.greysoh.dev/imterah/bismuthd/commons"
"crypto/cipher"
"crypto/rand"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"golang.org/x/crypto/chacha20poly1305"
)
type BismuthServer struct {
PublicKey *crypto.Key
PrivateKey *crypto.Key
pgp *crypto.PGPHandle
SymmetricEncryptionAlgorithm int
SigningServers []string
// This is what's called after a successful handshake & connection.
HandleConnection func(conn net.Conn) error
}
func (bismuth BismuthServer) encryptMessage(aead cipher.AEAD, msg []byte) ([]byte, error) {
nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(msg)+aead.Overhead())
if _, err := rand.Read(nonce); err != nil {
return []byte{}, err
}
encryptedMsg := aead.Seal(nonce, nonce, msg, nil)
return encryptedMsg, nil
}
func (bismuth BismuthServer) decryptMessage(aead cipher.AEAD, encMsg []byte) ([]byte, error) {
if len(encMsg) < aead.NonceSize() {
return []byte{}, fmt.Errorf("ciphertext too short")
}
// Split nonce and ciphertext.
nonce, ciphertext := encMsg[:aead.NonceSize()], encMsg[aead.NonceSize():]
// Decrypt the message and check it wasn't tampered with.
decryptedData, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return []byte{}, err
}
return decryptedData, nil
}
// This is what's called to handle a connnection for Bismuth.
func (bismuth BismuthServer) HandleProxy(conn net.Conn) error {
serverState := "keyHandshake"
var clientPublicKey *crypto.Key
var aead cipher.AEAD
for {
if serverState == "keyHandshake" {
dataModeByteArr := make([]byte, 1)
_, err := conn.Read(dataModeByteArr)
dataMode := dataModeByteArr[0]
if err != nil {
conn.Close()
return err
}
if dataMode == core.SendPublicKey {
pubKeyLengthBytes := make([]byte, 3)
_, err := conn.Read(pubKeyLengthBytes)
if err != nil {
conn.Close()
return err
}
pubKeyLength := core.Int24ToInt32(pubKeyLengthBytes)
publicKey := make([]byte, pubKeyLength)
_, err = conn.Read(publicKey)
pubKey := publicKey[:]
// Attempt to parse the public key
clientPublicKey, err = crypto.NewKey(pubKey)
if err != nil {
conn.Close()
return err
}
// Now, we send our key
pubKey, err = bismuth.PublicKey.GetPublicKey()
if err != nil {
conn.Close()
return err
}
pubKeyLength = uint32(len(pubKey))
core.Int32ToInt24(pubKeyLengthBytes, pubKeyLength)
conn.Write([]byte{core.SendPublicKey})
conn.Write(pubKeyLengthBytes)
conn.Write([]byte(pubKey))
serverState = "symmHandshake"
}
} else if serverState == "symmHandshake" {
dataModeByteArr := make([]byte, 1)
_, err := conn.Read(dataModeByteArr)
dataMode := dataModeByteArr[0]
if err != nil {
conn.Close()
return err
}
if dataMode == core.SwitchToSymmetricKey {
// TODO: Make this not hard-coded
symmetricKey := make([]byte, chacha20poly1305.KeySize)
encryptionData := []byte{
byte(bismuth.SymmetricEncryptionAlgorithm),
}
if _, err := rand.Read(symmetricKey); err != nil {
conn.Close()
return err
}
encryptionData = append(encryptionData, symmetricKey...)
encHandle, err := bismuth.pgp.Encryption().Recipient(clientPublicKey).New()
if err != nil {
conn.Close()
return err
}
pgpMessage, err := encHandle.Encrypt(encryptionData)
if err != nil {
conn.Close()
return err
}
encryptedMessage := pgpMessage.Bytes()
gpgMessageLengthBytes := make([]byte, 3)
core.Int32ToInt24(gpgMessageLengthBytes, uint32(len(encryptedMessage)))
conn.Write([]byte{core.SwitchToSymmetricKey})
conn.Write(gpgMessageLengthBytes)
conn.Write(encryptedMessage)
aead, err = chacha20poly1305.NewX(symmetricKey)
if err != nil {
conn.Close()
return err
}
serverState = "APITransmit"
}
} else if serverState == "APITransmit" {
// Currently the API is in a skeleton-like state, so the API is a bit of a no-op right now.
// Fuck you Stari.
packetSizeByteArr := make([]byte, 3)
if _, err := conn.Read(packetSizeByteArr); err != nil {
conn.Close()
return err
}
packetSize := core.Int24ToInt32(packetSizeByteArr)
encryptedPacket := make([]byte, packetSize)
packetSizeInt := int(packetSize)
totalPositionRead := 0
for packetSizeInt != totalPositionRead {
currentPosition, err := conn.Read(encryptedPacket[totalPositionRead:packetSizeInt])
totalPositionRead += currentPosition
if err != nil {
conn.Close()
return err
}
}
packet, err := bismuth.decryptMessage(aead, encryptedPacket)
if err != nil {
conn.Close()
return err
}
// Fuck it, we ball
if packet[0] == core.InitiateForwarding {
bmConn := core.BismuthConn{
Aead: aead,
PassedConn: conn,
MaxBufSize: core.ConnStandardMaxBufSize,
}
bmConn.DoInitSteps()
err := bismuth.HandleConnection(core.BismuthConnWrapped{
Bismuth: &bmConn,
})
return err
}
}
}
}
func NewBismuthServer(pubKey string, privKey string, signServers []string, encryptionAlgo int, connHandler func(conn net.Conn) error) (*BismuthServer, error) {
publicKey, err := crypto.NewKeyFromArmored(pubKey)
if err != nil {
return nil, err
}
privateKey, err := crypto.NewKeyFromArmored(privKey)
if err != nil {
return nil, err
}
pgp := crypto.PGP()
bismuth := BismuthServer{
PublicKey: publicKey,
PrivateKey: privateKey,
HandleConnection: connHandler,
SigningServers: signServers,
SymmetricEncryptionAlgorithm: encryptionAlgo,
pgp: pgp,
}
return &bismuth, nil
}

41
tests/create_keyring.go Normal file
View file

@ -0,0 +1,41 @@
package bismuthd_test
import (
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/ProtonMail/gopenpgp/v3/profile"
)
// Creates an armored GPG keyring
// Argument order is public key, then private key
func CreateKeyring(name, email string) (string, string, error) {
pgp := crypto.PGPWithProfile(profile.RFC9580())
privateKey, err := pgp.KeyGeneration().
AddUserId(name, email).
New().
GenerateKey()
if err != nil {
return "", "", err
}
publicKey, err := privateKey.ToPublic()
if err != nil {
return "", "", err
}
privateKeyArmored, err := privateKey.Armor()
if err != nil {
return "", "", err
}
publicKeyArmored, err := publicKey.Armor()
if err != nil {
return "", "", err
}
return publicKeyArmored, privateKeyArmored, nil
}

131
tests/integration_test.go Normal file
View file

@ -0,0 +1,131 @@
package bismuthd_test
import (
"bytes"
mathRand "math/rand"
"crypto/rand"
"net"
"strconv"
"testing"
"git.greysoh.dev/imterah/bismuthd/client"
"git.greysoh.dev/imterah/bismuthd/commons"
"git.greysoh.dev/imterah/bismuthd/server"
)
var testProtocolTxRxBufCount = 32
// Tests protocol transmitting and receiving
// This is designed to be a nightmare scenario for the protocol to push the limits on what would be possible.
func TestProtocolTxRx(t *testing.T) {
pubKeyCli, privKeyCli, err := CreateKeyring("alice", "alice@contoso.com")
if err != nil {
t.Fatalf("failed to generate 1st pair of keys (%s)", err.Error())
}
pubKeyServ, privKeyServ, err := CreateKeyring("bob", "bob@contoso.com")
if err != nil {
t.Fatalf("failed to generate 2nd pair of keys (%s)", err.Error())
}
t.Log("created keyrings")
randomDataSlices := [][]byte{}
for range testProtocolTxRxBufCount {
randomData := make([]byte, 65535)
_, err = rand.Read(randomData)
if err != nil {
t.Fatalf("failed to generate random data (%s)", err.Error())
}
randomDataSlices = append(randomDataSlices, randomData)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on TCP for localhost (%s)", err.Error())
}
bismuth, err := server.NewBismuthServer(pubKeyServ, privKeyServ, []string{}, commons.XChaCha20Poly1305, func(conn net.Conn) error {
for entryCount, randomDataSlice := range randomDataSlices {
_, err = conn.Write(randomDataSlice)
if err != nil {
t.Fatalf("failed to send randomDataSlice entry #%d (%s)", entryCount+1, err.Error())
}
}
return nil
})
// TODO: fix these warnings?
go func() {
conn, err := listener.Accept()
if err != nil {
t.Fatalf("failed to accept connection from listener (%s)", err.Error())
}
err = bismuth.HandleProxy(conn)
if err != nil {
t.Fatalf("failed to handle proxy in Bismuth (%s)", err.Error())
}
}()
port := listener.Addr().(*net.TCPAddr).Port
bismuthClient, err := client.New(pubKeyCli, privKeyCli)
if err != nil {
t.Fatalf("failed to initialize bismuthClient (%s)", err.Error())
}
originalConn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err != nil {
t.Fatalf("failed to connect to bismuth server (%s)", err.Error())
}
conn, err := bismuthClient.Conn(originalConn)
if err != nil {
t.Fatalf("bismuth client failed to handshake when connecting to server (%s)", err.Error())
}
// Now the real fun begins
for index, realBuffer := range randomDataSlices {
bufferSize := len(realBuffer)
totalBufferRead := 0
readBuffer := make([]byte, bufferSize)
for totalBufferRead != bufferSize {
randBufSize := mathRand.Intn(bufferSize - totalBufferRead)
if randBufSize == bufferSize || randBufSize == 0 {
continue
}
actualReadSize, err := conn.Read(readBuffer[totalBufferRead:])
if err != nil {
t.Fatalf("bismuth client failed to read in random slice #%d (%s)", index+1, err.Error())
}
totalBufferRead += actualReadSize
}
if !bytes.Equal(realBuffer, readBuffer) {
t.Fatalf("buffers are different (in random slice #%d)", index+1)
}
t.Logf("buffer #%d passed!", index+1)
}
}