Skip to content

Commit

Permalink
login/v2 implementation for Go (google#162)
Browse files Browse the repository at this point in the history
  • Loading branch information
burgerdev authored May 16, 2023
1 parent ca03687 commit 48d28f8
Show file tree
Hide file tree
Showing 6 changed files with 713 additions and 0 deletions.
30 changes: 30 additions & 0 deletions go/login/v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# GLOME Login Golang API v2

This package implements version 2 of the GLOME Login challenge response
protocol, as described in the [specification](../../../docs/glome-login.md) and
[RFD001](../../../docs/rfd/001.md).

## Design

The API is designed with two groups of users in mind: clients and servers.
In the GLOME Login protocol, clients generate *challenges* which are
*responded to* by servers. This is reflected in the two basic structs defined
here, `v2.Challenger` and `v2.Responder`.

The other important struct is `v2.Message`, which contains all context for the
authorization decision. The genral flow is:

1. Client creates a `v2.Challenger` object including server configuration.
This object is long-lived and can be reused.
2. An authorization decision needs to be made. The client phrases it in form of
a `v2.Message` and produces an encoded challenge.
3. The challenge is transferred to the server, which holds a long-lived
`v2.Responder` object that manages keys.
4. The server accepts the challenge, inspects the message and - if justified -
authorizes by handing out the response code.
5. The response code is transferred to the client, which validates the code and
grants access.

## Example

There's an example GLOME Login flow in [login_test.go](login_test.go).
131 changes: 131 additions & 0 deletions go/login/v2/challenger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package v2

import (
"crypto/rand"
"encoding/base64"
"errors"
"io"
"strings"

"github.com/google/glome/go/glome"
)

var (
// defaultMinResponseSize is the recommended minimal size of a tag so that
// brute-forcing is infeasible (see MIN_ENCODED_AUTHCODE_LEN in the C
// sources).
defaultMinResponseSize uint8 = 10
)

// Challenger produces challenges that a Responder can respond to.
type Challenger struct {
// PublicKey is the server's public key.
//
// This field must always be set.
PublicKey *glome.PublicKey

// The fields below are optional, their zero values work as expected.

// MinResponseLength is the minimal length of a response string required for verification.
//
// Recommended and default setting of this field is 10 (see protocol documentation).
MinResponseLength uint8

// MessageTagPrefixLength is the number of error detection bytes added to the challenge.
//
// Setting this to non-zero allows to detect a mismatch between the public key used by the
// client and the public key inferred by the server from index or public key prefix.
MessageTagPrefixLength uint8

// KeyIndex that the server uses to identify its private key.
//
// If unset, the challenge will be created with the public key prefix instead.
KeyIndex *uint8

// RNG generates ephemeral private keys for this Challenger.
//
// If unset, crypto/rand.Reader will be used.
// WARNING: Don't set this field unless you know what you are doing!
RNG io.Reader
}

// ClientChallenge is the internal representation of a challenge as it would be used on a client.
//
// ClientChallenge instances must be created by Challenger.Challenge()!
type ClientChallenge struct {
d *glome.Dialog
// The minimum length of an acceptable response.
min uint8

h *handshake
m []byte
}

// Challenge creates a clientChallenge object for this message and the Challenger configuration.
func (c *Challenger) Challenge(msg *Message) (*ClientChallenge, error) {
h := &handshake{}

rng := c.RNG
if rng == nil {
rng = rand.Reader
}
publicKey, key, err := glome.GenerateKeys(rng)
if err != nil {
return nil, err
}
h.PublicKey = publicKey

if c.PublicKey == nil {
return nil, errors.New("no public key")
}

if c.KeyIndex != nil {
h.Index = *c.KeyIndex
} else {
h.Prefix = &c.PublicKey[glome.PublicKeySize-1]
}

minResponseSize := uint8(c.MinResponseLength)
if minResponseSize == 0 {
minResponseSize = defaultMinResponseSize
}

d, err := key.TruncatedExchange(c.PublicKey, glome.MinTagSize)
if err != nil {
return nil, err
}

encodedMsg := []byte(msg.Encode())
if c.MessageTagPrefixLength > 0 {
h.MessageTagPrefix = d.Tag(encodedMsg, 0)[:c.MessageTagPrefixLength]
}

return &ClientChallenge{h: h, d: d, m: encodedMsg, min: minResponseSize}, nil
}

// Encode encodes the challenge into its URI path represenation.
func (c *ClientChallenge) Encode() string {
return strings.Join([]string{"v2", c.h.Encode(), string(c.m), ""}, "/")
}

// Verify a challenge response string.
func (c *ClientChallenge) Verify(s string) bool {
// In order to accept truncated base64 data, we need to handle special cases:
// - a single byte from an encoded triple can never decode correctly
// - 32 byte encode with a trailing padding character, which makes RawURLEncoding unhappy.
n := len(s)

// We check the response size here so that we don't need to deal with length conversion between
// Base64 and HMAC.
if n < int(c.min) {
return false
}
if n%4 == 1 || n == 44 {
n--
}
tag, err := base64.RawURLEncoding.DecodeString(s[:n])
if err != nil {
return false
}
return c.d.Check(tag, c.m, 0)
}
125 changes: 125 additions & 0 deletions go/login/v2/codec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package v2

import (
"bytes"
"encoding/base64"
"errors"
"net/url"
"strings"

"github.com/google/glome/go/glome"
)

// Message represents the context required for authorization.
type Message struct {
HostIDType string // type of identity
HostID string // identity of the target (e.g. hostname, serial number, etc.)
Action string // action that is being authorized
}

// escape a URI path minimally according to RFD001.
func escape(s string) string {
res := url.PathEscape(s)
for _, c := range "!*'();:@&=+$,[]" {
st := string(c)
res = strings.Replace(res, url.PathEscape(st), st, -1)
}
return res
}

// Encode the message into its URI path representation.
func (m *Message) Encode() string {
sb := &strings.Builder{}
if len(m.HostIDType) > 0 {
sb.WriteString(escape(m.HostIDType))
sb.WriteByte(':')
}
sb.WriteString(escape(m.HostID))
sb.WriteByte('/')
sb.WriteString(escape(m.Action))
return sb.String()
}

func decodeMessage(s string) (*Message, error) {
m := &Message{}

subs := strings.Split(s, "/")
if len(subs) != 2 {
return nil, errors.New("message format error")
}

hostSegment, err := url.PathUnescape(subs[0])
if err != nil {
return nil, err
}
hostParts := strings.SplitN(hostSegment, ":", 2)
if len(hostParts) > 1 {
m.HostIDType = hostParts[0]
m.HostID = hostParts[1]
} else {
m.HostID = hostParts[0]
}

action, err := url.PathUnescape(subs[1])
if err != nil {
return nil, err
}
m.Action = action

return m, nil
}

type handshake struct {
Index uint8
Prefix *byte

PublicKey *glome.PublicKey
MessageTagPrefix []byte
}

func (h *handshake) Encode() string {
data := bytes.NewBuffer(nil)
if h.Prefix != nil {
data.WriteByte(*h.Prefix)
} else {
data.WriteByte(1<<7 | h.Index)
}
data.Write(h.PublicKey[:])
data.Write(h.MessageTagPrefix)

return base64.URLEncoding.EncodeToString(data.Bytes())
}

func decodeHandshake(s string) (*handshake, error) {
data, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return nil, err
}
if len(data) < 33 {
return nil, errors.New("handshake too short")
}

h := &handshake{}

if data[0]>>7 == 0 { // check Prefix-type
h.Prefix = &data[0]
} else {
h.Index = data[0] % (1 << 7)
}

key, err := glome.PublicKeyFromSlice(data[1 : glome.PublicKeySize+1])
if err != nil {
return nil, err
}
h.PublicKey = key

msgTagPrefix := data[glome.PublicKeySize+1:]
if len(msgTagPrefix) > glome.MaxTagSize {
return nil, errors.New("message tag prefix too long")
}
if len(msgTagPrefix) > 0 {
h.MessageTagPrefix = msgTagPrefix
}

return h, nil
}
55 changes: 55 additions & 0 deletions go/login/v2/codec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package v2

import (
"reflect"
"testing"
)

type messageTestCase struct {
msg *Message
encoded string
}

var messageTestCases = []messageTestCase{
{
encoded: "myhost/root",
msg: &Message{HostID: "myhost", Action: "root"},
},
{
encoded: "mytype:myhost/root",
msg: &Message{HostIDType: "mytype", HostID: "myhost", Action: "root"},
},
{
encoded: "escaping/special%20action%CC",
msg: &Message{HostID: "escaping", Action: "special action\xcc"},
},
{
encoded: "pairs/user=root;exec=%2Fbin%2Fmksh",
msg: &Message{HostID: "pairs", Action: "user=root;exec=/bin/mksh"},
},
}

func TestEncodeMessage(t *testing.T) {
for _, tc := range messageTestCases {
t.Run(tc.encoded, func(t *testing.T) {
got := tc.msg.Encode()
if got != tc.encoded {
t.Errorf("%#v.Encode() == %q, want %q", tc.msg, got, tc.encoded)
}
})
}
}

func TestDecodeMessage(t *testing.T) {
for _, tc := range messageTestCases {
t.Run(tc.encoded, func(t *testing.T) {
got, err := decodeMessage(tc.encoded)
if err != nil {
t.Fatalf("decodeMessage(%q) failed: %v", tc.encoded, err)
}
if !reflect.DeepEqual(got, tc.msg) {
t.Errorf("decodeMessage(%q) == %#v, want %#v", tc.encoded, got, tc.msg)
}
})
}
}
Loading

0 comments on commit 48d28f8

Please sign in to comment.