forked from google/glome
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
login/v2 implementation for Go (google#162)
- Loading branch information
Showing
6 changed files
with
713 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.