author | Alberto Bertogli
<albertito@blitiri.com.ar> 2015-09-20 16:31:04 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2015-10-15 19:32:31 UTC |
.gitignore | +14 | -0 |
INSTALL.md | +73 | -0 |
LICENSE | +25 | -0 |
Makefile | +18 | -0 |
README.md | +26 | -0 |
TODO | +6 | -0 |
internal/client/config.go | +118 | -0 |
internal/client/grpc.go | +204 | -0 |
internal/proto/dummy.go | +4 | -0 |
internal/proto/remoteu2f.pb.go | +215 | -0 |
internal/proto/remoteu2f.proto | +44 | -0 |
libpam/.clang-format | +7 | -0 |
libpam/.gitignore | +3 | -0 |
libpam/Makefile | +17 | -0 |
libpam/pam_prompt_exec.c | +453 | -0 |
remoteu2f-cli/main.go | +367 | -0 |
remoteu2f-proxy/embedded_data.go | +770 | -0 |
remoteu2f-proxy/init/default/remoteu2f-proxy | +8 | -0 |
remoteu2f-proxy/init/systemd/remoteu2f-proxy.service | +11 | -0 |
remoteu2f-proxy/init/upstart/remoteu2f-proxy.conf | +16 | -0 |
remoteu2f-proxy/main.go | +125 | -0 |
remoteu2f-proxy/ratelimit.go | +31 | -0 |
remoteu2f-proxy/server.go | +368 | -0 |
remoteu2f-proxy/to_embed/authenticate.html | +31 | -0 |
remoteu2f-proxy/to_embed/register.html | +29 | -0 |
remoteu2f-proxy/to_embed/remoteu2f.js | +25 | -0 |
remoteu2f-proxy/to_embed/u2f_api.js | +654 | -0 |
remoteu2f-proxy/tools/embed.go | +118 | -0 |
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc7449 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ + +# vim swap files. +.*.sw* + +# keys and certificates that may be used for testing. +*.pem + +# token files that may be used for testing. +tokens + +# binaries we generate. +remoteu2f-proxy/remoteu2f-proxy +remoteu2f-cli/remoteu2f-cli +libpam/*.so diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..73b7793 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,73 @@ + +# Installing remoteu2f + + +## Building and installing the proxy + +You will need a publicly available server, a valid SSL certificate, and two +open ports (one for HTTP and another one for GRPC). + +First, build and install the binary: + + mkdir remoteu2f; cd remoteu2f + export GOPATH=$PWD + + go get blitiri.com.ar/go/remoteu2f/remoteu2f-proxy + sudo cp bin/remoteu2f-proxy /usr/local/bin/ + + +Then, generate some random tokens that will be used to authorize clients: + + ( for i in `seq 1 10`; do + head -c60 /dev/urandom | sha256sum -b | cut -d ' ' -f 1 ; + done ) > /etc/remoteu2f-proxy/tokens + +Use one token per user, or one token per (user, host). +Never share tokens between different users, this is very insecure and will +become even more so in the future. +Tokens are arbitrary strings, prepending the name of the user can help you +know who you gave them to. + + +Finally, launch the binary. You can use the provided upstart or systemd +examples to help you with this, depending on your system. + + +## Building and installing the ssh side + +You will need `pam_prompt_exec.so` and `remoteu2f-cli`: + + mkdir remoteu2f; cd remoteu2f + export GOPATH=$PWD + + go get blitiri.com.ar/go/remoteu2f/remoteu2f-cli + sudo cp bin/remoteu2f-cli /usr/local/bin/ + + cd src/blitiri.com.ar/go/remoteu2f/libpam + make + sudo cp pam_prompt_exec.so /lib/security + + +Then, configure PAM for ssh (or sudo, or the service of your choice) by +editing /etc/pam.d/sshd (or equivalent) and adding the following at the +bottom: + + auth required pam_prompt_exec.so /usr/local/bin/remoteu2f-cli pam --nullok + + +### Configuring a user + +Once you have completed the server install above, each each user that wants to +use remoteu2f has to configure their client. + +Run `remoteu2f-cli init` and follow the instructions. + +Take note of the backup codes so you can access without your security key in +an emergency. + + +Then use `remoteu2f-cli register` to register your security key. You can +register as many keys as you want. + +Use `remoteu2f-cli auth` to verify that it works. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3f40c7e --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Unless otherwise noted, this project and its files are under the MIT licence, +which is reproduced below (taken from http://opensource.org/licenses/MIT). + +----- + +Copyright (c) 2015 Alberto Bertogli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab9a2d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ + +all: libpam remoteu2f-cli/remoteu2f-cli remoteu2f-proxy/remoteu2f-proxy + +libpam: + $(MAKE) -C libpam + +remoteu2f-cli/remoteu2f-cli: + cd remoteu2f-cli && go build + +remoteu2f-proxy/remoteu2f-proxy: + cd remoteu2f-proxy && go build + +clean: + $(MAKE) -C libpam clean + rm -f remoteu2f-cli/remoteu2f-cli + rm -f remoteu2f-proxy/remoteu2f-proxy + +.PHONY: all libpam clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..17e0d79 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ + +# remoteu2f - Use U2F for PAM remotely + +[remoteu2f](http://blitiri.com.ar/p/remoteu2f) is a project that enables the +use of a [FIDO U2F](https://www.yubico.com/applications/fido/) security key +remotely, through a lightly-trusted proxy. + +It is useful mainly to use as a second factor authentication for ssh and +sudo-over-ssh. For example: + + - User does "ssh server", and enters their password. + - Server shows a one-time randomly generated URL. + - User visits the URL, and inserts/touches the security key. + - The SSH server allows access. + +It is written in Go, with some C for PAM integration. + +For how to install and use it, please see the +[installation instructions](INSTALL.md). + + +## Contact + +If you want to report bugs, send patches, or have any questions or comments, +just let me know at albertito@blitiri.com.ar. + diff --git a/TODO b/TODO new file mode 100644 index 0000000..c92dbbb --- /dev/null +++ b/TODO @@ -0,0 +1,6 @@ + +- Add more documentation, specially about how it works and trust model. +- Write a remoteu2f-agent to avoid having to open the browser. +- Support security key counters. +- Make the proxy rate limit an option. + diff --git a/internal/client/config.go b/internal/client/config.go new file mode 100644 index 0000000..372b763 --- /dev/null +++ b/internal/client/config.go @@ -0,0 +1,118 @@ +package client + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "io/ioutil" + "math/big" + "os" +) + +// Default configuration directory, relative to the user's home directory. +const DefaultConfigDir = ".remoteu2f" + +// Default configuration file name. +const DefaultConfigFileName = "config" + +// Client configuration structure. +// We read and write this to disk in toml format. +type Config struct { + // Address of the GRPC server to use. + Addr string + + // Client token used to request authorization to the server. + Token string + + // U2F AppID to use. Usually "https://address/". + AppID string + + // Backup codes, that allow emergency access. + BackupCodes map[string]bool + + // Registrations. + // Map of description -> marshalled registration. + Registrations map[string][]byte +} + +func ReadConfig(path string) (*Config, error) { + data, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + c := &Config{} + return c, json.Unmarshal(data, c) +} + +func DefaultConfigFullPath(home string) string { + if home == "" { + home = os.Getenv("HOME") + } + return home + "/" + DefaultConfigDir + "/" + DefaultConfigFileName +} + +func ReadDefaultConfig(home string) (*Config, error) { + return ReadConfig(DefaultConfigFullPath(home)) +} + +func (c *Config) Write(path string) error { + f, err := ioutil.TempFile("", "remoteu2f-config-") + if err != nil { + return err + } + defer f.Close() + + b, err := json.Marshal(c) + if err != nil { + os.Remove(f.Name()) + return err + } + + // Format the json for increased readability. + var out bytes.Buffer + json.Indent(&out, b, "", " ") + out.WriteTo(f) + + return os.Rename(f.Name(), path) +} + +func (c *Config) WriteToDefaultPath(home string) error { + if home == "" { + home = os.Getenv("HOME") + } + dir := home + "/" + DefaultConfigDir + path := dir + "/" + DefaultConfigFileName + os.MkdirAll(dir, 0700) + err := c.Write(path) + if err != nil { + return fmt.Errorf("error writing to %q: %v", path, err) + } + return nil +} + +func (c *Config) NewBackupCodes() error { + codes := map[string]bool{} + + // 6 codes of 6 digits each. + for i := 0; i < 6; i++ { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return err + } + sn := fmt.Sprintf("%06d", n) + codes[sn] = true + } + + c.BackupCodes = codes + return nil +} + +func (c *Config) RegistrationValues() [][]byte { + var rs [][]byte + for _, r := range c.Registrations { + rs = append(rs, r) + } + return rs +} diff --git a/internal/client/grpc.go b/internal/client/grpc.go new file mode 100644 index 0000000..cd02483 --- /dev/null +++ b/internal/client/grpc.go @@ -0,0 +1,204 @@ +package client + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/tstranex/u2f" + "golang.org/x/net/context" + "golang.org/x/oauth2" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/oauth" + + pb "blitiri.com.ar/go/remoteu2f/internal/proto" +) + +type RemoteU2FClient struct { + c pb.RemoteU2FClient +} + +func GRPCClient(addr, token, caFile string) (*RemoteU2FClient, error) { + var err error + var tCreds credentials.TransportAuthenticator + if caFile == "" { + tCreds = credentials.NewClientTLSFromCert(nil, "") + } else { + tCreds, err = credentials.NewClientTLSFromFile(caFile, "") + if err != nil { + return nil, fmt.Errorf("error reading CA file: %s", err) + } + } + + t := oauth2.Token{ + AccessToken: token, + TokenType: "Bearer", + } + rpcCreds := oauth.NewOauthAccess(&t) + + conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(tCreds), + grpc.WithPerRPCCredentials(rpcCreds), + grpc.WithBlock(), + grpc.WithTimeout(30*time.Second)) + if err != nil { + return nil, fmt.Errorf("error connecting to server: %s", err) + } + + c := pb.NewRemoteU2FClient(conn) + return &RemoteU2FClient{c}, nil +} + +func (c *RemoteU2FClient) GetAppID() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + r, err := c.c.GetAppID(ctx, &pb.Void{}) + if err != nil { + return "", err + } + + return r.Url, nil +} + +type PendingRegister struct { + Key *pb.Url + challenge *u2f.Challenge +} + +func (c *RemoteU2FClient) PrepareRegister(msg, appID string) (*PendingRegister, error) { + var trustedFacets = []string{appID} + + challenge, err := u2f.NewChallenge(appID, trustedFacets) + if err != nil { + return nil, fmt.Errorf("u2f.NewChallenge error: %v", err) + } + + j, err := json.Marshal(challenge.RegisterRequest()) + if err != nil { + return nil, fmt.Errorf("json marshalling error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + key, err := c.c.PrepareOp(ctx, &pb.Prepare{j, msg, pb.Prepare_REGISTER}) + if err != nil { + return nil, fmt.Errorf("error preparing: %v", err) + } + + return &PendingRegister{key, challenge}, nil +} + +func (c *RemoteU2FClient) CompleteRegister(p *PendingRegister) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + resp, err := c.c.GetOpResponse(ctx, p.Key) + if err != nil { + return nil, fmt.Errorf("error registering: %v", err) + } + + var regResp u2f.RegisterResponse + if err := json.Unmarshal(resp.Json, ®Resp); err != nil { + return nil, fmt.Errorf("invalid response: %s", err) + } + + config := u2f.Config{ + // Unfortunately we don't have the attestation certs of many keys, + // so skip the check for now. + SkipAttestationVerify: true, + } + + reg, err := u2f.Register(regResp, *p.challenge, &config) + if err != nil { + return nil, fmt.Errorf("u2f.Register error: %v", err) + } + + // We save the marshalled registration object, which we use later to get + // it back for authorization purposes. + return reg.MarshalBinary() +} + +type PendingAuth struct { + Key *pb.Url + + // Registrations we sent auth requests for. + regs []*u2f.Registration + + // Challenges matching each registration. + challenges []*u2f.Challenge +} + +func (c *RemoteU2FClient) PrepareAuthentication(msg, appID string, marshalledRegs [][]byte) ( + *PendingAuth, error) { + + var trustedFacets = []string{appID} + + pa := &PendingAuth{} + signReqs := []*u2f.SignRequest{} + + // Generate one signature request for each registration. + for _, mreg := range marshalledRegs { + reg := &u2f.Registration{} + err := reg.UnmarshalBinary(mreg) + if err != nil { + return nil, fmt.Errorf("u2f.ParseRegistration: %v\n", err) + } + + // Can/should we reuse the challenge for all the registrations? + challenge, err := u2f.NewChallenge(appID, trustedFacets) + if err != nil { + return nil, fmt.Errorf("u2f.NewChallenge error: %v", err) + } + + sr := challenge.SignRequest(*reg) + signReqs = append(signReqs, sr) + + pa.challenges = append(pa.challenges, challenge) + pa.regs = append(pa.regs, reg) + } + + j, err := json.Marshal(signReqs) + if err != nil { + return nil, fmt.Errorf("json marshalling error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + key, err := c.c.PrepareOp( + ctx, &pb.Prepare{j, msg, pb.Prepare_AUTHENTICATE}) + if err != nil { + return nil, fmt.Errorf("error preparing: %v", err) + } + + pa.Key = key + + return pa, nil +} + +func (c *RemoteU2FClient) CompleteAuthentication(pa *PendingAuth) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + resp, err := c.c.GetOpResponse(ctx, pa.Key) + if err != nil { + return fmt.Errorf("error authenticating: %v", err) + } + + var signResp u2f.SignResponse + if err := json.Unmarshal(resp.Json, &signResp); err != nil { + return fmt.Errorf("invalid response: %s", err) + } + + for i, reg := range pa.regs { + // TODO: support counters. + _, err = reg.Authenticate(signResp, *pa.challenges[i], 0) + if err == nil { + return nil + } + } + + return fmt.Errorf("authenticate error: matching registration not found") +} diff --git a/internal/proto/dummy.go b/internal/proto/dummy.go new file mode 100644 index 0000000..16249dd --- /dev/null +++ b/internal/proto/dummy.go @@ -0,0 +1,4 @@ +package remoteu2f + +// Generate the protobuf+grpc service. +//go:generate protoc --go_out=plugins=grpc:. remoteu2f.proto diff --git a/internal/proto/remoteu2f.pb.go b/internal/proto/remoteu2f.pb.go new file mode 100644 index 0000000..ade1d3b --- /dev/null +++ b/internal/proto/remoteu2f.pb.go @@ -0,0 +1,215 @@ +// Code generated by protoc-gen-go. +// source: remoteu2f.proto +// DO NOT EDIT! + +/* +Package remoteu2f is a generated protocol buffer package. + +It is generated from these files: + remoteu2f.proto + +It has these top-level messages: + Void + Url + Prepare + Response +*/ +package remoteu2f + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +import ( + context "golang.org/x/net/context" + grpc "google.golang.org/grpc" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// Request type, used only for selecting the web page template (no +// functional behaviour difference from an RPC perspective). +type Prepare_RType int32 + +const ( + Prepare_UNKNOWN Prepare_RType = 0 + Prepare_REGISTER Prepare_RType = 1 + Prepare_AUTHENTICATE Prepare_RType = 2 +) + +var Prepare_RType_name = map[int32]string{ + 0: "UNKNOWN", + 1: "REGISTER", + 2: "AUTHENTICATE", +} +var Prepare_RType_value = map[string]int32{ + "UNKNOWN": 0, + "REGISTER": 1, + "AUTHENTICATE": 2, +} + +func (x Prepare_RType) String() string { + return proto.EnumName(Prepare_RType_name, int32(x)) +} + +// Generic empty message, for RPCs that don't need one. +type Void struct { +} + +func (m *Void) Reset() { *m = Void{} } +func (m *Void) String() string { return proto.CompactTextString(m) } +func (*Void) ProtoMessage() {} + +// Generic message containing a request key and URL. +type Url struct { + Key string `protobuf:"bytes,1,opt,name=key" json:"key,omitempty"` + Url string `protobuf:"bytes,2,opt,name=url" json:"url,omitempty"` +} + +func (m *Url) Reset() { *m = Url{} } +func (m *Url) String() string { return proto.CompactTextString(m) } +func (*Url) ProtoMessage() {} + +// Prepare an operation. +type Prepare struct { + // Generic json content to return. + Json []byte `protobuf:"bytes,1,opt,name=json,proto3" json:"json,omitempty"` + // User-readable message to show on the web page. + Msg string `protobuf:"bytes,2,opt,name=msg" json:"msg,omitempty"` + Rtype Prepare_RType `protobuf:"varint,3,opt,name=rtype,enum=remoteu2f.Prepare_RType" json:"rtype,omitempty"` +} + +func (m *Prepare) Reset() { *m = Prepare{} } +func (m *Prepare) String() string { return proto.CompactTextString(m) } +func (*Prepare) ProtoMessage() {} + +// Operation response. +type Response struct { + Json []byte `protobuf:"bytes,1,opt,name=json,proto3" json:"json,omitempty"` +} + +func (m *Response) Reset() { *m = Response{} } +func (m *Response) String() string { return proto.CompactTextString(m) } +func (*Response) ProtoMessage() {} + +func init() { + proto.RegisterEnum("remoteu2f.Prepare_RType", Prepare_RType_name, Prepare_RType_value) +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// Client API for RemoteU2F service + +type RemoteU2FClient interface { + PrepareOp(ctx context.Context, in *Prepare, opts ...grpc.CallOption) (*Url, error) + GetOpResponse(ctx context.Context, in *Url, opts ...grpc.CallOption) (*Response, error) + GetAppID(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Url, error) +} + +type remoteU2FClient struct { + cc *grpc.ClientConn +} + +func NewRemoteU2FClient(cc *grpc.ClientConn) RemoteU2FClient { + return &remoteU2FClient{cc} +} + +func (c *remoteU2FClient) PrepareOp(ctx context.Context, in *Prepare, opts ...grpc.CallOption) (*Url, error) { + out := new(Url) + err := grpc.Invoke(ctx, "/remoteu2f.RemoteU2F/PrepareOp", in, out, c.cc, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *remoteU2FClient) GetOpResponse(ctx context.Context, in *Url, opts ...grpc.CallOption) (*Response, error) { + out := new(Response) + err := grpc.Invoke(ctx, "/remoteu2f.RemoteU2F/GetOpResponse", in, out, c.cc, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *remoteU2FClient) GetAppID(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Url, error) { + out := new(Url) + err := grpc.Invoke(ctx, "/remoteu2f.RemoteU2F/GetAppID", in, out, c.cc, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// Server API for RemoteU2F service + +type RemoteU2FServer interface { + PrepareOp(context.Context, *Prepare) (*Url, error) + GetOpResponse(context.Context, *Url) (*Response, error) + GetAppID(context.Context, *Void) (*Url, error) +} + +func RegisterRemoteU2FServer(s *grpc.Server, srv RemoteU2FServer) { + s.RegisterService(&_RemoteU2F_serviceDesc, srv) +} + +func _RemoteU2F_PrepareOp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error) (interface{}, error) { + in := new(Prepare) + if err := dec(in); err != nil { + return nil, err + } + out, err := srv.(RemoteU2FServer).PrepareOp(ctx, in) + if err != nil { + return nil, err + } + return out, nil +} + +func _RemoteU2F_GetOpResponse_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error) (interface{}, error) { + in := new(Url) + if err := dec(in); err != nil { + return nil, err + } + out, err := srv.(RemoteU2FServer).GetOpResponse(ctx, in) + if err != nil { + return nil, err + } + return out, nil +} + +func _RemoteU2F_GetAppID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error) (interface{}, error) { + in := new(Void) + if err := dec(in); err != nil { + return nil, err + } + out, err := srv.(RemoteU2FServer).GetAppID(ctx, in) + if err != nil { + return nil, err + } + return out, nil +} + +var _RemoteU2F_serviceDesc = grpc.ServiceDesc{ + ServiceName: "remoteu2f.RemoteU2F", + HandlerType: (*RemoteU2FServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "PrepareOp", + Handler: _RemoteU2F_PrepareOp_Handler, + }, + { + MethodName: "GetOpResponse", + Handler: _RemoteU2F_GetOpResponse_Handler, + }, + { + MethodName: "GetAppID", + Handler: _RemoteU2F_GetAppID_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, +} diff --git a/internal/proto/remoteu2f.proto b/internal/proto/remoteu2f.proto new file mode 100644 index 0000000..16be073 --- /dev/null +++ b/internal/proto/remoteu2f.proto @@ -0,0 +1,44 @@ + +syntax = "proto3"; + +package remoteu2f; + +// Generic empty message, for RPCs that don't need one. +message Void { +} + +// Generic message containing a request key and URL. +message Url { + string key = 1; + string url = 2; +} + +// Prepare an operation. +message Prepare { + // Generic json content to return. + bytes json = 1; + + // User-readable message to show on the web page. + string msg = 2; + + // Request type, used only for selecting the web page template (no + // functional behaviour difference from an RPC perspective). + enum RType { + UNKNOWN = 0; + REGISTER = 1; + AUTHENTICATE = 2; + } + RType rtype = 3; +} + +// Operation response. +message Response { + bytes json = 1; +} + + +service RemoteU2F { + rpc PrepareOp(Prepare) returns (Url); + rpc GetOpResponse(Url) returns (Response); + rpc GetAppID(Void) returns (Url); +} diff --git a/libpam/.clang-format b/libpam/.clang-format new file mode 100644 index 0000000..9819398 --- /dev/null +++ b/libpam/.clang-format @@ -0,0 +1,7 @@ +BasedOnStyle: LLVM +IndentWidth: 8 +UseTab: Always +BreakBeforeBraces: Linux +AllowShortIfStatementsOnASingleLine: false +IndentCaseLabels: false + diff --git a/libpam/.gitignore b/libpam/.gitignore new file mode 100644 index 0000000..7f56666 --- /dev/null +++ b/libpam/.gitignore @@ -0,0 +1,3 @@ + +*.so + diff --git a/libpam/Makefile b/libpam/Makefile new file mode 100644 index 0000000..4c24eb1 --- /dev/null +++ b/libpam/Makefile @@ -0,0 +1,17 @@ + +all: pam_prompt_exec.so + +pam_prompt_exec.so: pam_prompt_exec.c + $(CC) $(CFLAGS) -std=c11 -Wall -fPIC -shared -lpam $< -o $@ + + +# This is just for convenience during development. +# Unfortunately most distributions don't include a general binary and we have +# to pick a version. +fmt: + clang-format-3.7 -i pam_prompt_exec.c + +clean: + rm -f pam_prompt_exec.so + +.PHONY: all fmt clean diff --git a/libpam/pam_prompt_exec.c b/libpam/pam_prompt_exec.c new file mode 100644 index 0000000..c5014f7 --- /dev/null +++ b/libpam/pam_prompt_exec.c @@ -0,0 +1,453 @@ +// pam_prompt_exec is a PAM module which calls an external command, which +// prompts the user for input. +// +// It is analogous to pam_exec(8), but allows the program to print a prompt +// (by writing to stdout) and then reading input back (by reading from stdin). +// The command is run as the PAM user. + +// We use features from POSIX 2008. +#define _POSIX_C_SOURCE 200809L + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <sys/wait.h> +#include <syslog.h> +#include <time.h> +#include <unistd.h> + +#define PAM_SM_AUTH +#define PAM_SM_ACCOUNT +#define PAM_SM_SESSION +#define PAM_SM_PASSWORD + +#include <security/pam_modules.h> +#include <security/pam_modutil.h> +#include <security/pam_ext.h> + +// Buffer size to use when reading stdout. +// Note this is the maximum size we read, 4k should be plenty for our +// interactive use. +static const int BUFSIZE = 4 * 1024; + +// The exit status of the child if we have to exit before exec()ing. +// Nothing special about 217, it's just easy to find. +static const int CHILD_ERROR = 217; + +// Send text to PAM, get a response back. +static struct pam_response *pam_talk(pam_handle_t *pamh, char *text) +{ + int rv; + struct pam_conv *conv; + rv = pam_get_item(pamh, PAM_CONV, (void *)&conv); + if (rv != PAM_SUCCESS) { + pam_syslog(pamh, LOG_ERR, "pam_get_item() failed: %d", rv); + return NULL; + } + + const struct pam_message msg = { + .msg_style = PAM_PROMPT_ECHO_ON, .msg = text, + }; + const struct pam_message *pmsg = &msg; + + struct pam_response *resp = NULL; + rv = conv->conv(1, &pmsg, &resp, conv->appdata_ptr); + if (rv != PAM_SUCCESS) { + pam_syslog(pamh, LOG_ERR, "conv->conv() failed: %d", rv); + return NULL; + } + + return resp; +} + +// Move the given fd to an fd >= 3; exit if it fails. +static int move_to_high_fd(pam_handle_t *pamh, int fd) +{ + while (fd < 3) { + fd = dup(fd); + if (fd < 0) { + pam_syslog(pamh, LOG_ERR, "dup() failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + } + + return fd; +} + +// Build a "NAME=VALUE" string to use in the environment list. +static char *build_env_value(pam_handle_t *pamh, const char *name, + const char *value) +{ + // We'll return "name=value\0" + // 3 extra bytes to account for the =, \0 and one more just to be + // extra cautious. + size_t bsize = strlen(name) + strlen(value) + 3; + char *buf = calloc(bsize, 1); + if (buf == NULL) { + pam_syslog(pamh, LOG_ERR, "calloc(env item) failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + snprintf(buf, bsize, "%s=%s", name, value); + return buf; +} + +// Build the environment for the child, or die trying. +static char **build_child_env(pam_handle_t *pamh, const char *pam_type) +{ + char **env = pam_getenvlist(pamh); + if (env == NULL) { + pam_syslog(pamh, LOG_ERR, "pam_getenvlist() failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + // Find how many elements are in env. + int envlen = 0; + for (envlen = 0; env[envlen] != NULL; envlen++) + ; + + // Variables to copy. + struct { + int item; + const char *name; + } items[] = { + {PAM_SERVICE, "PAM_SERVICE"}, {PAM_USER, "PAM_USER"}, + {PAM_TTY, "PAM_TTY"}, {PAM_RHOST, "PAM_RHOST"}, + {PAM_RUSER, "PAM_RUSER"}, + }; + const int nitems = 5; + + // Realloc to account for nitems + PAM_TYPE (below) + NULL. + env = realloc(env, (envlen + nitems + 2) * sizeof(char *)); + if (env == NULL) { + pam_syslog(pamh, LOG_ERR, "realloc() failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + // Add the items to the environment. + for (int i = 0; i < nitems; i++) { + const void *item; + + // Skip items that are not found. + if (pam_get_item(pamh, items[i].item, &item) != PAM_SUCCESS || + item == NULL) { + continue; + } + + // Add it to the environment. + env[envlen] = + build_env_value(pamh, items[i].name, (const char *)item); + envlen++; + env[envlen] = NULL; + } + + // And PAM_TYPE to the type we know. + env[envlen] = build_env_value(pamh, "PAM_TYPE", pam_type); + envlen++; + env[envlen] = NULL; + + return env; +} + +// Drop privileges to the user we are validating. +// PAM modules usually (but not always) run as root, this will drop privileges +// to that user. +// It is only called during the child process initialization. +static void drop_privileges(pam_handle_t *pamh) +{ + // Get the user information. + const char *user = NULL; + int rv = pam_get_user(pamh, &user, NULL); + if (rv != PAM_SUCCESS || user == NULL) { + pam_syslog(pamh, LOG_ERR, "could not get PAM user: %d", rv); + _exit(CHILD_ERROR); + } + + struct passwd *pwd = getpwnam(user); + if (pwd == NULL) { + pam_syslog(pamh, LOG_ERR, "could not get user info: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + // Change the group. + uid_t old_gid = getegid(); + if (old_gid != pwd->pw_gid && setegid(pwd->pw_gid)) { + pam_syslog(pamh, LOG_ERR, "error in setegid(): %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + // Change the user. + uid_t old_uid = geteuid(); + if (old_uid != pwd->pw_uid && seteuid(pwd->pw_uid)) { + pam_syslog(pamh, LOG_ERR, "error in seteuid(): %s", + strerror(errno)); + _exit(CHILD_ERROR); + } +} + +// Like read() but either fails or returns a complete read. +static ssize_t full_read(int fd, void *buf, size_t count) +{ + ssize_t rv; + size_t c = 0; + + while (c < count) { + rv = read(fd, (char *)buf + c, count - c); + if (rv < 0) { + return rv; + } else if (rv == 0) { + return c; + } + + c += rv; + } + + return count; +} + +// Like write() but either fails or returns a complete write. +static ssize_t full_write(int fd, const void *buf, size_t count) +{ + ssize_t rv; + size_t c = 0; + + while (c < count) { + rv = write(fd, (char *)buf + c, count - c); + if (rv < 0) + return rv; + + c += rv; + } + + return count; +} + +static int prompt_exec(const char *pam_type, pam_handle_t *pamh, int argc, + const char **argv) +{ + int binary_path_pos = -1; + for (int i = 0; i < argc; i++) { + // Stop if we got to the binary path. + if (argv[i][0] == '/') { + binary_path_pos = i; + break; + } + + if (strncmp(argv[i], "type=", 5) == 0) { + // Ignore if we are not invoked with the expected + // pam type. + if (strcmp(pam_type, &argv[i][5]) != 0) { + return PAM_IGNORE; + } + } + } + + if (binary_path_pos == -1) { + pam_syslog(pamh, LOG_ERR, "No program to exec"); + return PAM_SERVICE_ERR; + } + + // Set up stdin pipe. + int stdin_fds[2] = {-1, -1}; + if (pipe(stdin_fds) != 0) { + pam_syslog(pamh, LOG_ERR, "Could not create stdin pipe: %s", + strerror(errno)); + return PAM_SYSTEM_ERR; + } + + // Set up stdout pipe. + int stdout_fds[2] = {-1, -1}; + if (pipe(stdout_fds) != 0) { + pam_syslog(pamh, LOG_ERR, "Could not create stdout pipe: %s", + strerror(errno)); + return PAM_SYSTEM_ERR; + } + + pid_t pid = fork(); + if (pid == -1) { + pam_syslog(pamh, LOG_ERR, "Could not fork(): %s", + strerror(errno)); + return PAM_SYSTEM_ERR; + } else if (pid > 0) { // Parent. + // Close the fds we don't use. + close(stdin_fds[0]); + close(stdout_fds[1]); + + // Read the prompt from stdout. + char buf[BUFSIZE]; + int buflen = -1; + memset(buf, 0, BUFSIZE); + + buflen = full_read(stdout_fds[0], buf, BUFSIZE - 1); + if (buflen < 0) { + pam_syslog(pamh, LOG_ERR, "Could not from stdout: %s", + strerror(errno)); + return PAM_SYSTEM_ERR; + } + close(stdout_fds[0]); + + // Talk to the user, but only if there's a prompt. + if (buflen > 0) { + // Converse via PAM. + struct pam_response *response = pam_talk(pamh, buf); + if (response == NULL) { + return PAM_SYSTEM_ERR; + } + + // Send response back via stdin. + int rlen = strlen(response->resp); + int rv = full_write(stdin_fds[1], response->resp, rlen); + if (rv != rlen) { + // Note this is just informational: the child + // may not care about this and may have even + // closed it. + pam_syslog(pamh, LOG_NOTICE, + "Could not write to stdin: %s", + strerror(errno)); + } + + close(stdin_fds[1]); + + free(response->resp); + free(response); + } + + // Wait for the program to die. + pid_t rv; + int status = 0; + while ((rv = waitpid(pid, &status, 0)) == -1 && errno == EINTR) + ; + if (rv != pid) { + pam_syslog(pamh, LOG_ERR, "waitpid() failed: %s", + strerror(errno)); + return PAM_SYSTEM_ERR; + } + + const char *binary = argv[binary_path_pos]; + + if (status == 0) { + return PAM_SUCCESS; + } else if (WIFEXITED(status)) { + pam_error(pamh, "%s failed, exit code: %d", binary, + WEXITSTATUS(status)); + return PAM_SYSTEM_ERR; + } else if (WIFSIGNALED(status)) { + pam_error(pamh, "%s failed, signal: %d", binary, + WTERMSIG(status)); + return PAM_SYSTEM_ERR; + } else { + pam_error(pamh, "%s failed, unknown status: 0x%x", + binary, status); + return PAM_SYSTEM_ERR; + } + } else { // Child. + // Close the fds we don't use. + close(stdin_fds[1]); + close(stdout_fds[0]); + + // Move stdin and stdout pipes to high file descriptors, so we + // can dup() them safely later. + int stdin_fd = move_to_high_fd(pamh, stdin_fds[0]); + int stdout_fd = move_to_high_fd(pamh, stdout_fds[1]); + + // Now move stdin and stdout to the canonical fds. + if (dup2(stdin_fd, STDIN_FILENO) == -1) { + pam_syslog(pamh, LOG_ERR, "dup2(stdin) failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + if (dup2(stdout_fd, STDOUT_FILENO) == -1) { + pam_syslog(pamh, LOG_ERR, "dup2(stdout) failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + // Close unused fds, just in case. + for (int i = 3; i < sysconf(_SC_OPEN_MAX); i++) { + close(i); + } + + // Run in a new session, just in case. + if (setsid() == -1) { + pam_syslog(pamh, LOG_ERR, "setsid() failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + // Drop privileges by changing to the new user. + drop_privileges(pamh); + + // Set up the environment, with the PAM environment + the + // variables with the PAM information to pass on. + char **child_env = build_child_env(pamh, pam_type); + + // Set up the child's argv. + int child_argc = argc - binary_path_pos + 2; + char **child_argv = calloc(child_argc, sizeof(char *)); + if (child_argv == NULL) { + pam_syslog(pamh, LOG_ERR, "calloc(argc) failed: %s", + strerror(errno)); + _exit(CHILD_ERROR); + } + + int i, j; + for (i = binary_path_pos, j = 0; i < argc; i++, j++) { + child_argv[j] = strdup(argv[i]); + } + child_argv[j] = NULL; + + // Exec! + execve(child_argv[0], child_argv, child_env); + pam_syslog(pamh, LOG_ERR, "execve(%s) failed: %s", + strerror(errno), child_argv[0]); + _exit(CHILD_ERROR); + } +} + +/* + * PAM functions for auth, session and account. + */ + +int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, + const char **argv) +{ + return prompt_exec("auth", pamh, argc, argv); +} + +int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv) +{ + return PAM_IGNORE; +} + +int pam_sm_chauthtok(pam_handle_t *pamh, int flags, int argc, const char **argv) +{ + if (flags & PAM_PRELIM_CHECK) + return PAM_SUCCESS; + return prompt_exec("password", pamh, argc, argv); +} + +int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) +{ + return prompt_exec("account", pamh, argc, argv); +} + +int pam_sm_open_session(pam_handle_t *pamh, int flags, int argc, + const char **argv) +{ + return prompt_exec("open_session", pamh, argc, argv); +} + +int pam_sm_close_session(pam_handle_t *pamh, int flags, int argc, + const char **argv) +{ + return prompt_exec("close_session", pamh, argc, argv); +} diff --git a/remoteu2f-cli/main.go b/remoteu2f-cli/main.go new file mode 100644 index 0000000..d66123c --- /dev/null +++ b/remoteu2f-cli/main.go @@ -0,0 +1,367 @@ +// remoteu2f command-line interface +package main + +import ( + "bufio" + "fmt" + "os" + "os/user" + "sort" + "strings" + + "github.com/codegangsta/cli" + + "blitiri.com.ar/go/remoteu2f/internal/client" +) + +var stdinScanner *bufio.Scanner + +// readLine reads a line from os.Stdin and returns it. +// It exits the process on errors. +func readLine() string { + if !stdinScanner.Scan() && stdinScanner.Err() != nil { + fmt.Printf("Error reading from stdin: %v\n", stdinScanner.Err()) + os.Exit(1) + } + return strings.TrimSpace(stdinScanner.Text()) +} + +func main() { + app := cli.NewApp() + app.Name = "remoteu2f-cli" + app.Usage = "remoteu2f command line tool" + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "ca_file", + Usage: "path to the CA file (default: use system's)", + }, + } + app.Commands = []cli.Command{ + { + Name: "init", + Usage: "Create initial configuration (interactive)", + Action: Init, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "override", + Usage: "override the configuration if it exists", + }, + }, + }, + { + Name: "register", + Usage: "Register a new security key", + Action: Register, + }, + { + Name: "auth", + Usage: "Perform a test authentication", + Action: Authenticate, + }, + { + Name: "new_backup_codes", + Usage: "Generate new backup codes, remove the old ones", + Action: NewBackupCodes, + }, + { + Name: "print_config", + Usage: "Print the config (useful for debugging)", + Action: PrintConfig, + }, + { + Name: "pam", + Usage: "Perform an authentication for PAM", + Action: PAM, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "nullok", + Usage: "return success if there is no configuration", + }, + }, + }, + } + + // Initialize the stdin scanner so the subcommands can use it. + stdinScanner = bufio.NewScanner(os.Stdin) + + app.RunAndExitOnError() +} + +func fatalf(format string, a ...interface{}) { + fmt.Printf(format, a...) + os.Exit(1) +} + +func mustReadConfig() *client.Config { + conf, err := client.ReadDefaultConfig("") + if err != nil { + fatalf("Error reading config: %v\n", err) + } + + return conf +} + +func mustWriteConfig(c *client.Config, homedir string) { + err := c.WriteToDefaultPath(homedir) + if err != nil { + fatalf("Error writing config: %v\n", err) + } +} + +func mustGRPCClient(addr, token, caFile string) *client.RemoteU2FClient { + c, err := client.GRPCClient(addr, token, caFile) + if err != nil { + fatalf("Error connecting with the server: %v\n", err) + } + + return c +} + +func mustUserInfo() (string, string) { + user, err := user.Current() + if err != nil { + fatalf("error getting current user: %v", err) + } + + hostname, err := os.Hostname() + if err != nil { + fatalf("error getting hostname: %v", err) + } + + return user.Username, hostname +} + +func mustLookupHomeDir(username string) string { + info, err := user.Lookup(username) + if err != nil { + fatalf("Could not find $HOME for user: %v\n", err) + } + + return info.HomeDir +} + +func printBackupCodes(conf *client.Config) { + // Sort the codes so we get stable and more friendly output. + var codes []string + for c := range conf.BackupCodes { + codes = append(codes, c) + } + sort.Strings(codes) + + for _, c := range codes { + fmt.Printf(" %v\n", c) + } +} + +func printRegistrations(conf *client.Config) { + // Sort the descriptions so we get stable and more friendly output. + var ds []string + for d := range conf.Registrations { + ds = append(ds, d) + } + sort.Strings(ds) + + for _, d := range ds { + fmt.Printf(" %q\n", d) + } +} + +func Init(ctx *cli.Context) { + if !ctx.Bool("override") { + // We don't want to accidentally override the config. + _, err := client.ReadDefaultConfig("") + if err == nil { + fmt.Printf("Configuration already exists at %s\n", + client.DefaultConfigFullPath("")) + fmt.Printf("Use --override to continue anyway.\n") + os.Exit(1) + } + } + + fmt.Printf("- GRPC server address to use? (e.g. 'mydomain.com:8801')\n") + addr := readLine() + fmt.Printf("- Authorization token? (given to you by the server admin)\n") + token := readLine() + + fmt.Printf("- Contacting server...\n") + c, err := client.GRPCClient(addr, token, ctx.GlobalString("ca_file")) + if err != nil { + fmt.Printf("Error connecting with the server: %v\n", err) + fmt.Printf("Check the parameters above and try again.\n") + os.Exit(1) + } + + appID, err := c.GetAppID() + if err != nil { + fmt.Printf("RPC error: %v\n", err) + fmt.Printf("Check the parameters above and try again.\n") + os.Exit(1) + } + + fmt.Printf("It worked! AppID: %s\n", appID) + + conf := &client.Config{ + Addr: addr, + Token: token, + AppID: appID, + Registrations: map[string][]byte{}, + } + + err = conf.NewBackupCodes() + if err != nil { + fatalf("Error generating backup codes: %v\n", err) + } + + mustWriteConfig(conf, "") + fmt.Printf("Config written to %s\n", client.DefaultConfigFullPath("")) + + fmt.Printf("\n") + fmt.Printf("Please write down your backup codes:\n") + printBackupCodes(conf) + + fmt.Printf("\n") + fmt.Printf("All done!\n") + fmt.Printf("To register a security key, run: remoteu2f-cli register\n") +} + +func PrintConfig(ctx *cli.Context) { + conf := mustReadConfig() + fmt.Printf("GRPC address: %s\n", conf.Addr) + fmt.Printf("Client token: %s\n", conf.Token) + fmt.Printf("Application ID: %s\n", conf.AppID) + + fmt.Printf("Registered keys:\n") + printRegistrations(conf) + + fmt.Printf("Backup codes:\n") + printBackupCodes(conf) +} + +func Register(ctx *cli.Context) { + conf := mustReadConfig() + c := mustGRPCClient(conf.Addr, conf.Token, ctx.GlobalString("ca_file")) + + user, hostname := mustUserInfo() + msg := fmt.Sprintf("%s@%s", user, hostname) + + pr, err := c.PrepareRegister(msg, conf.AppID) + if err != nil { + fatalf("Error preparing registration: %v\n", err) + } + fmt.Printf("Go to: %s\n", pr.Key.Url) + + reg, err := c.CompleteRegister(pr) + if err != nil { + fatalf("Error completing registration: %v\n", err) + } + + fmt.Printf("Description for this security key:\n") + desc := readLine() + + if conf.Registrations == nil { + conf.Registrations = map[string][]byte{} + } + conf.Registrations[desc] = reg + + mustWriteConfig(conf, "") + fmt.Println("Success, registration written to config") +} + +func Authenticate(ctx *cli.Context) { + conf := mustReadConfig() + c := mustGRPCClient(conf.Addr, conf.Token, ctx.GlobalString("ca_file")) + + user, hostname := mustUserInfo() + msg := fmt.Sprintf("%s@%s", user, hostname) + + if len(conf.Registrations) == 0 { + fmt.Printf("Error: no registrations found\n") + fatalf("To register a security key, run: remoteu2f-cli register\n") + } + + pa, err := c.PrepareAuthentication( + msg, conf.AppID, conf.RegistrationValues()) + if err != nil { + fatalf("Error preparing authentication: %v\n", err) + } + fmt.Printf("Go to: %s\n", pa.Key.Url) + + err = c.CompleteAuthentication(pa) + if err != nil { + fatalf("Error completing authentication: %v\n", err) + } + + fmt.Println("Authentication succeeded") +} + +func NewBackupCodes(ctx *cli.Context) { + conf := mustReadConfig() + err := conf.NewBackupCodes() + if err != nil { + fatalf("Error generating new backup codes: %v\n", err) + } + + mustWriteConfig(conf, "") + + fmt.Printf("New backup codes:\n") + for s, _ := range conf.BackupCodes { + fmt.Printf(" %v\n", s) + } +} + +func PAM(ctx *cli.Context) { + // We need to find the user's home first. + username := os.Getenv("PAM_USER") + homedir := mustLookupHomeDir(username) + + nullok := ctx.Bool("nullok") + conf, err := client.ReadDefaultConfig(homedir) + if err != nil { + if nullok { + os.Exit(0) + } else { + fatalf("Error reading config: %v\n", err) + } + } + + if len(conf.Registrations) == 0 { + if nullok { + os.Exit(0) + } else { + fatalf("Error: no registrations found\n") + } + } + + c := mustGRPCClient(conf.Addr, conf.Token, ctx.GlobalString("ca_file")) + + user, hostname := mustUserInfo() + msg := fmt.Sprintf("%s@%s", user, hostname) + + pa, err := c.PrepareAuthentication( + msg, conf.AppID, conf.RegistrationValues()) + if err != nil { + fatalf("Error preparing authentication: %v\n", err) + } + + fmt.Printf("Authenticate here and press enter: %s\n", pa.Key.Url) + + // Closing stdout makes pam_prompt_exec send the prompt over. + os.Stdout.Close() + + // Read input, and check if it's a backup code. + // Never take a backup code of less than 6 characters, just in case some + // data handling error makes them appear in conf.BackupCodes. + input := readLine() + if _, ok := conf.BackupCodes[input]; len(input) >= 6 && ok { + delete(conf.BackupCodes, input) + mustWriteConfig(conf, homedir) + os.Exit(0) + } + + err = c.CompleteAuthentication(pa) + if err != nil { + fatalf("Error completing authentication: %v\n", err) + } + + os.Exit(0) +} diff --git a/remoteu2f-proxy/embedded_data.go b/remoteu2f-proxy/embedded_data.go new file mode 100644 index 0000000..5c9b3ae --- /dev/null +++ b/remoteu2f-proxy/embedded_data.go @@ -0,0 +1,770 @@ + +package main + +// File auto-generated by embed.go. + + +// to_embed/authenticate.html ----- 8< ----- 8< ----- 8< ----- 8< ----- + +// authenticate_html contains the content of to_embed/authenticate.html. +const authenticate_html = `<!DOCTYPE html> +<html> + <head> + </head> + + <body> + <h1>remoteu2f - authenticate</h1> + + <h2>{{.Message}}</h2> + + <p class="instructions"> + Please insert/touch your security key to authenticate. + </p> + + <p class="status"> + Status: <span id="status">waiting for security key</span> + </p> + + <script + type="text/javascript" + src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js" + integrity="sha384-8gBf6Y4YYq7Jx97PIqmTwLPin4hxIzQw5aDmUg/DDhul9fFpbbLcLh3nTIIDJKhx" + crossorigin="anonymous"></script> + <script type="text/javascript" src="remoteu2f.js"></script> + <script type="text/javascript" src="u2f_api.js"></script> + <script> + u2f.sign({{.Request}}, handleKeyResponse, 2*60); + </script> + + </body> +</html> +` + + +// to_embed/register.html ----- 8< ----- 8< ----- 8< ----- 8< ----- + +// register_html contains the content of to_embed/register.html. +const register_html = `<!DOCTYPE html> +<html> + <head> + </head> + + <body> + <h1>remoteu2f - register</h1> + + <h2>{{.Message}}</h2> + + <p class="instructions"> + Please insert/touch your security key to complete the registration. + </p> + + <p class="status"> + Status: <span id="status">waiting for security key</span> + </p> + + <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js" + integrity="sha384-8gBf6Y4YYq7Jx97PIqmTwLPin4hxIzQw5aDmUg/DDhul9fFpbbLcLh3nTIIDJKhx" + crossorigin="anonymous"></script> + <script type="text/javascript" src="remoteu2f.js"></script> + <script type="text/javascript" src="u2f_api.js"></script> + <script> + u2f.register([{{.Request}}], [], handleKeyResponse, 2*60) + </script> + + </body> +</html> +` + + +// to_embed/remoteu2f.js ----- 8< ----- 8< ----- 8< ----- 8< ----- + +// remoteu2f_js contains the content of to_embed/remoteu2f.js. +const remoteu2f_js = ` +// This function gets called when the security key gives us a response to our +// register/sign request. +function handleKeyResponse(resp) { + if (resp.errorCode != undefined && resp.errorCode != u2f.ErrorCodes.OK) { + codeToText = { + 0: "OK", + 1: "General error (hardware issues?)", + 2: "Bad request (please report)", + 3: "Unsupported configuration (please report)", + 4: "Device ineligible, did you forget to register it?", + 5: "Timed out waiting for security key", + } + + $('#status').text( + codeToText[resp.errorCode] + " -- " + + resp.errorMessage); + return; + } + + $('#status').text('sending response'); + $.post('response', JSON.stringify(resp)).done(function() { + $('#status').text('done'); + }); +} +` + + +// to_embed/u2f_api.js ----- 8< ----- 8< ----- 8< ----- 8< ----- + +// u2f_api_js contains the content of to_embed/u2f_api.js. +const u2f_api_js = `// Copyright 2014-2015 Google Inc. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +// remoteu2f note: Obtained from the reference code at +// https://github.com/google/u2f-ref-code/ + +/** + * @fileoverview The U2F api. + */ + +'use strict'; + +/** Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * The U2F extension id + * @type {string} + * @const + */ +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response' +}; + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + +/** + * A message type for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * signRequests: Array<u2f.SignRequest>, + * registerRequests: ?Array<u2f.RegisterRequest>, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.Request; + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.Response; + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string, + * appId: string + * }} + */ +u2f.RegisterRequest; + +/** + * Data object for a registration response. + * @typedef {{ + * registrationData: string, + * clientData: string + * }} + */ +u2f.RegisterResponse; + + +// Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format a return a sign request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.WrappedChromeRuntimePort_.prototype.formatSignRequest_ = + function(signRequests, timeoutSeconds, reqId) { + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format a return a register request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.WrappedChromeRuntimePort_.prototype.formatRegisterRequest_ = + function(signRequests, registerRequests, timeoutSeconds, reqId) { + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentLocation = /** @type {string} */ (message); + document.location = intentLocation; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + responseObject['requestId'] = this.requestId_; + } + + /* Sign responses from the authenticator do not conform to U2F, + * convert to U2F here. */ + responseObject = this.doResponseFixups_(responseObject); + callback({'data': responseObject}); +}; + +/** + * Fixup the response provided by the Authenticator to conform with + * the U2F spec. + * @param {Object} responseData + * @return {Object} the U2F compliant response object + */ +u2f.WrappedAuthenticatorPort_.prototype.doResponseFixups_ = + function(responseObject) { + if (responseObject.hasOwnProperty('responseData')) { + return responseObject; + } else if (this.requestObject_['type'] != u2f.MessageTypes.U2F_SIGN_REQUEST) { + // Only sign responses require fixups. If this is not a response + // to a sign request, then an internal error has occurred. + return { + 'type': u2f.MessageTypes.U2F_REGISTER_RESPONSE, + 'responseData': { + 'errorCode': u2f.ErrorCodes.OTHER_ERROR, + 'errorMessage': 'Internal error: invalid response from Authenticator' + } + }; + } + + /* Non-conformant sign response, do fixups. */ + var encodedChallengeObject = responseObject['challenge']; + if (typeof encodedChallengeObject !== 'undefined') { + var challengeObject = JSON.parse(atob(encodedChallengeObject)); + var serverChallenge = challengeObject['challenge']; + var challengesList = this.requestObject_['signData']; + var requestChallengeObject = null; + for (var i = 0; i < challengesList.length; i++) { + var challengeObject = challengesList[i]; + if (challengeObject['keyHandle'] == responseObject['keyHandle']) { + requestChallengeObject = challengeObject; + break; + } + } + } + var responseData = { + 'errorCode': responseObject['resultCode'], + 'keyHandle': responseObject['keyHandle'], + 'signatureData': responseObject['signature'], + 'clientData': encodedChallengeObject + }; + return { + 'type': u2f.MessageTypes.U2F_SIGN_RESPONSE, + 'responseData': responseData, + 'requestId': responseObject['requestId'] + } +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Format a return a sign request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds (ignored for now) + * @param {number} reqId + * @return {string} + */ +u2f.WrappedAuthenticatorPort_.prototype.formatSignRequest_ = + function(signRequests, timeoutSeconds, reqId) { + if (!signRequests || signRequests.length == 0) { + return null; + } + /* TODO(fixme): stash away requestId, as the authenticator app does + * not return it for sign responses. */ + this.requestId_ = reqId; + /* TODO(fixme): stash away the signRequests, to deal with the legacy + * response format returned by the Authenticator app. */ + this.requestObject_ = { + 'type': u2f.MessageTypes.U2F_SIGN_REQUEST, + 'signData': signRequests, + 'requestId': reqId, + 'timeout': timeoutSeconds + }; + + var appId = signRequests[0]['appId']; + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.appId=' + encodeURIComponent(appId) + + ';S.eventId=' + reqId + + ';S.challenges=' + + encodeURIComponent( + JSON.stringify(this.getBrowserDataList_(signRequests))) + ';end'; + return intentUrl; +}; + +/** + * Get the browser data objects from the challenge list + * @param {Array} challenges list of challenges + * @return {Array} list of browser data objects + * @private + */ +u2f.WrappedAuthenticatorPort_ + .prototype.getBrowserDataList_ = function(challenges) { + return challenges + .map(function(challenge) { + var browserData = { + 'typ': 'navigator.id.getAssertion', + 'challenge': challenge['challenge'] + }; + var challengeObject = { + 'challenge' : browserData, + 'keyHandle' : challenge['keyHandle'] + }; + return challengeObject; + }); +}; + +/** + * Format a return a register request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} enrollChallenges + * @param {number} timeoutSeconds (ignored for now) + * @param {number} reqId + * @return {Object} + */ +u2f.WrappedAuthenticatorPort_.prototype.formatRegisterRequest_ = + function(signRequests, enrollChallenges, timeoutSeconds, reqId) { + if (!enrollChallenges || enrollChallenges.length == 0) { + return null; + } + // Assume the appId is the same for all enroll challenges. + var appId = enrollChallenges[0]['appId']; + var registerRequests = []; + for (var i = 0; i < enrollChallenges.length; i++) { + var registerRequest = { + 'challenge': enrollChallenges[i]['challenge'], + 'version': enrollChallenges[i]['version'] + }; + if (enrollChallenges[i]['appId'] != appId) { + // Only include the appId when it differs from the first appId. + registerRequest['appId'] = enrollChallenges[i]['appId']; + } + registerRequests.push(registerRequest); + } + var registeredKeys = []; + if (signRequests) { + for (i = 0; i < signRequests.length; i++) { + var key = { + 'keyHandle': signRequests[i]['keyHandle'], + 'version': signRequests[i]['version'] + }; + // Only include the appId when it differs from the appId that's + // being registered now. + if (signRequests[i]['appId'] != appId) { + key['appId'] = signRequests[i]['appId']; + } + registeredKeys.push(key); + } + } + var request = { + 'type': u2f.MessageTypes.U2F_REGISTER_REQUEST, + 'appId': appId, + 'registerRequests': registerRequests, + 'registeredKeys': registeredKeys, + 'requestId': reqId, + 'timeoutSeconds': timeoutSeconds + }; + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(request)) + + ';end'; + /* TODO(fixme): stash away requestId, this is is not necessary for + * register requests, but here to keep parity with sign. + */ + this.requestId_ = reqId; + return intentUrl; +}; + + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +// High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + * |function((u2f.Error|u2f.SignResponse)))>} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.<u2f.Response>} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {Array<u2f.SignRequest>} signRequests + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(signRequests, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = port.formatSignRequest_(signRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.SignRequest>} signRequests + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(registerRequests, signRequests, + callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = port.formatRegisterRequest_( + signRequests, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; +` + + + diff --git a/remoteu2f-proxy/init/default/remoteu2f-proxy b/remoteu2f-proxy/init/default/remoteu2f-proxy new file mode 100644 index 0000000..a54a1cd --- /dev/null +++ b/remoteu2f-proxy/init/default/remoteu2f-proxy @@ -0,0 +1,8 @@ +# /etc/default/remoteu2f-proxy + +OPTS=" \ +-base_url=https://yourdomain:8800 \ +-tls_cert=/etc/ssl/yourcert.crt \ +-tls_key=/etc/ssl/yourkey.key \ +-token_file=/etc/remoteu2f-proxy/tokens \ +" diff --git a/remoteu2f-proxy/init/systemd/remoteu2f-proxy.service b/remoteu2f-proxy/init/systemd/remoteu2f-proxy.service new file mode 100644 index 0000000..a9697f1 --- /dev/null +++ b/remoteu2f-proxy/init/systemd/remoteu2f-proxy.service @@ -0,0 +1,11 @@ +[Unit] +Description = remoteu2f proxy + +[Service] +EnvironmentFile = /etc/default/remoteu2f-proxy +ExecStart = /usr/local/bin/remoteu2f-proxy -logtostderr $OPTS +Type = simple +User = www-data + +[Install] +WantedBy = multi-user.target diff --git a/remoteu2f-proxy/init/upstart/remoteu2f-proxy.conf b/remoteu2f-proxy/init/upstart/remoteu2f-proxy.conf new file mode 100644 index 0000000..c9e3359 --- /dev/null +++ b/remoteu2f-proxy/init/upstart/remoteu2f-proxy.conf @@ -0,0 +1,16 @@ +# /etc/init/remoteu2f-proxy.conf + +description "remoteu2f-proxy" + +start on filesystem +stop on runlevel [016] + +respawn + +pre-start exec test -x /usr/local/bin/remoteu2f-proxy || { stop; exit 0; } + +script + test ! -r /etc/default/remoteu2f-proxy || . /etc/default/remoteu2f-proxy + exec start-stop-daemon --start --chuid www-data:www-data --exec /usr/local/bin/remoteu2f-proxy -- $OPTS +end script + diff --git a/remoteu2f-proxy/main.go b/remoteu2f-proxy/main.go new file mode 100644 index 0000000..dae8b25 --- /dev/null +++ b/remoteu2f-proxy/main.go @@ -0,0 +1,125 @@ +// remoteu2f-proxy is the http+grpc server for remoteu2f. +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + + // Enable profiling via HTTP. + // We will only use this if --debug_addr is given. + _ "net/http/pprof" + + "github.com/golang/glog" +) + +// Command-line flags. +var ( + baseURL = flag.String("base_url", "", + "base URL to send the clients (e.g. https://domain:8800); "+ + "must be https and not have a trailing '/'") + + httpAddr = flag.String("http_addr", ":8800", + "address where to listen for HTTP requests") + grpcAddr = flag.String("grpc_addr", ":8801", + "address where to listen for GPRC requests") + debugAddr = flag.String("debug_addr", "", + "address where to listen for debug/trace/profile HTTP requests") + + tlsCert = flag.String("tls_cert", "cert.pem", + "file containing the TLS certificate to use") + tlsKey = flag.String("tls_key", "key.pem", + "file containing the TLS key to use") + grpcCert = flag.String("tls_cert_grpc", "", + "if set, use this certificate for GRPC instead of --tls_cert") + grpcKey = flag.String("tls_key_grpc", "", + "if set, use this key for GRPC instead of --tls_key") + + tokenFile = flag.String("token_file", "tokens", + "file containing the valid client access tokens") +) + +func validateBaseURL(s string) error { + u, err := url.Parse(*baseURL) + if err != nil { + return fmt.Errorf("malformed url:", err) + } + if u.Scheme != "https" { + return fmt.Errorf("scheme MUST be 'https'") + } + if u.Path != "" { + return fmt.Errorf("path MUST be empty (not even a trailing '/')") + } + + return nil +} + +func getValidTokens(path string) (map[string]bool, error) { + contents, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + tokens := map[string]bool{} + for _, t := range strings.Split(string(contents), "\n") { + // Remove empty lines, and make sure all tokens have a minimum lenght. + t = strings.TrimSpace(t) + if t == "" { + continue + } + if len(t) < 10 { + return nil, fmt.Errorf( + "token %q too short (minimum lenght: 10)", t) + } + + tokens[t] = true + } + + return tokens, nil +} + +func main() { + flag.Parse() + + // We have strict requirements on baseURL because we use it as the appID. + if err := validateBaseURL(*baseURL); err != nil { + glog.Fatalf("invalid --base_url: %s", err) + } + + validTokens, err := getValidTokens(*tokenFile) + if err != nil { + glog.Fatalf("error reading token file: %s", err) + } + + s := NewServer() + s.BaseURL = *baseURL + s.HTTPAddr = *httpAddr + s.GRPCAddr = *grpcAddr + s.ValidTokens = validTokens + + s.HTTPCert = *tlsCert + s.HTTPKey = *tlsKey + s.GRPCCert = *tlsCert + s.GRPCKey = *tlsKey + if *grpcCert != "" { + s.GRPCCert = *grpcCert + } + if *grpcKey != "" { + s.GRPCKey = *grpcKey + } + + if *debugAddr != "" { + // Launch the default HTTP server at the given address. + // This is the one pprof and grpc register automatically against. + go func() { + glog.Infof("Debug HTTP listening on %s", *debugAddr) + err := http.ListenAndServe(*debugAddr, nil) + glog.Fatalf("Debug HTTP exiting: %s", err) + }() + } + + s.ListenAndServe() +} diff --git a/remoteu2f-proxy/ratelimit.go b/remoteu2f-proxy/ratelimit.go new file mode 100644 index 0000000..87b8cc6 --- /dev/null +++ b/remoteu2f-proxy/ratelimit.go @@ -0,0 +1,31 @@ +package main + +import ( + "sync" + "time" +) + +// Simple and trivial rate limiter. +// Only covers from gross mis-usage, this is not accurate or featureful. +type RateLimiter struct { + Interval time.Duration + MaxCount uint + + mu sync.Mutex + count uint + lastTick time.Time +} + +func (r *RateLimiter) Allowed() bool { + r.mu.Lock() + defer r.mu.Unlock() + + if time.Since(r.lastTick) > r.Interval { + r.lastTick = time.Now() + r.count = 0 + return true + } + + r.count++ + return r.count < r.MaxCount +} diff --git a/remoteu2f-proxy/server.go b/remoteu2f-proxy/server.go new file mode 100644 index 0000000..86543a2 --- /dev/null +++ b/remoteu2f-proxy/server.go @@ -0,0 +1,368 @@ +package main + +// Embed the html and js files we need. +//go:generate go run tools/embed.go to_embed/*.html to_embed/*.js + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "text/template" + "time" + + "github.com/golang/glog" + "github.com/gorilla/mux" + "golang.org/x/net/context" + "golang.org/x/net/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + + pb "blitiri.com.ar/go/remoteu2f/internal/proto" +) + +// randomID generates a random ID to use as part of the URL and to identify a +// single operation. +func randomID() (string, error) { + // 64 bit from crypto/rand should be enough for our purposes. + // These are reasonably short-lived (2m) and we have rate limiting. + b := make([]byte, 8) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// We treat Registrations and Authentication requests the same. +type PendingOp struct { + // JSON that came in the Prepare message. + prepared []byte + + // Message for the user, from the Prepare message. + msg string + + // Request type, from the Prepare message. + rtype pb.Prepare_RType + + // Channel we use to send the reply. + reply chan []byte +} + +type Server struct { + BaseURL string + HTTPAddr string + GRPCAddr string + ValidTokens map[string]bool + + HTTPCert string + HTTPKey string + GRPCCert string + GRPCKey string + + mu sync.Mutex + ops map[string]*PendingOp + + ratelimiter *RateLimiter +} + +func NewServer() *Server { + // Rate-limit requests to 50/s. + // TODO: Make this configurable. + rl := &RateLimiter{ + Interval: 1 * time.Second, + MaxCount: 50, + } + + return &Server{ + ops: map[string]*PendingOp{}, + ratelimiter: rl, + } +} + +func (s *Server) removeOp(key string) { + s.mu.Lock() + defer s.mu.Unlock() + + op, ok := s.ops[key] + if !ok { + return + } + + delete(s.ops, key) + close(op.reply) +} + +func (s *Server) removeOpAfter(key string, after time.Duration) { + <-time.After(after) + s.removeOp(key) +} + +func (s *Server) getOp(key string) (*PendingOp, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + op, ok := s.ops[key] + return op, ok +} + +func (s *Server) checkOauth(ctx context.Context) error { + md, ok := metadata.FromContext(ctx) + if !ok || md == nil { + return grpc.Errorf(codes.PermissionDenied, "MD not found") + } + + for _, tokenMD := range md["authorization"] { + // tokenMD is: <type> SP <token>. Extract the token + ps := strings.SplitN(tokenMD, " ", 2) + if len(ps) != 2 { + return grpc.Errorf(codes.PermissionDenied, "invalid token format") + } + token := ps[1] + + if _, ok := s.ValidTokens[token]; ok { + return nil + } + } + + return grpc.Errorf(codes.PermissionDenied, "token not authorized") +} + +func (s *Server) PrepareOp(ctx context.Context, p *pb.Prepare) (*pb.Url, error) { + if !s.ratelimiter.Allowed() { + return nil, grpc.Errorf(codes.Unavailable, "rate limited") + } + + if err := s.checkOauth(ctx); err != nil { + return nil, err + } + + key, err := randomID() + if err != nil { + return nil, err + } + + op := &PendingOp{ + prepared: p.Json, + msg: p.Msg, + rtype: p.Rtype, + + // Buffered channel makes us not block if the grpc client goes away. + reply: make(chan []byte, 1), + } + + s.mu.Lock() + s.ops[key] = op + s.mu.Unlock() + + // We don't expect to have enough pending operations for the number of + // goroutines to be a problem. + go s.removeOpAfter(key, 3*time.Minute) + + return &pb.Url{ + Url: s.BaseURL + "/" + key + "/", + Key: key, + }, nil +} + +func (s *Server) GetOpResponse(ctx context.Context, url *pb.Url) (*pb.Response, error) { + if !s.ratelimiter.Allowed() { + return nil, grpc.Errorf(codes.Unavailable, "rate limited") + } + + // We could be more paranoid and check against the token that prepared the + // operation, but this is good enough for now. + if err := s.checkOauth(ctx); err != nil { + return nil, err + } + + op, ok := s.getOp(url.Key) + if !ok { + return nil, grpc.Errorf(codes.FailedPrecondition, "key not found") + } + + reply, ok := <-op.reply + if !ok { + return nil, grpc.Errorf(codes.DeadlineExceeded, + "timed out waiting for reply") + } + + // Remove the data once we've sent a reply. + s.removeOp(url.Key) + + return &pb.Response{reply}, nil +} + +func (s *Server) GetAppID(ctx context.Context, _ *pb.Void) (*pb.Url, error) { + if !s.ratelimiter.Allowed() { + return nil, grpc.Errorf(codes.Unavailable, "rate limited") + } + + if err := s.checkOauth(ctx); err != nil { + return nil, err + } + + r := &pb.Url{ + Key: "", + Url: s.BaseURL, + } + return r, nil +} + +func keyFromRequest(r *http.Request) string { + vs := mux.Vars(r) + return vs["key"] +} + +var registerTmpl = template.Must( + template.New("register").Parse(register_html)) +var authenticateTmpl = template.Must( + template.New("authenticate").Parse(authenticate_html)) + +// Serve the key-specific index. +func (s *Server) IndexHandler(w http.ResponseWriter, r *http.Request) { + tr := trace.New("http", "index") + defer tr.Finish() + + if !s.ratelimiter.Allowed() { + tr.LazyPrintf("rate limited") + tr.SetError() + http.Error(w, "too many requests", 429) + return + } + + key := keyFromRequest(r) + tr.LazyPrintf("key: %s", key) + op, ok := s.getOp(key) + if !ok { + tr.LazyPrintf("404 error") + tr.SetError() + http.NotFound(w, r) + return + } + + var err error + data := struct { + Message string + Request string + }{ + op.msg, + string(op.prepared), + } + switch op.rtype { + case pb.Prepare_REGISTER: + err = registerTmpl.Execute(w, data) + case pb.Prepare_AUTHENTICATE: + err = authenticateTmpl.Execute(w, data) + default: + err = fmt.Errorf("unknown operation type %v", op.rtype) + } + + if err != nil { + tr.LazyPrintf("render error: %v", err) + tr.SetError() + http.Error(w, "error rendering", http.StatusBadRequest) + return + } +} + +// StaticHandler returns an HTTP handler for the given path and content. +func (s *Server) StaticHandler(path, content string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + tr := trace.New("http", path) + defer tr.Finish() + + if !s.ratelimiter.Allowed() { + tr.LazyPrintf("rate limited") + tr.SetError() + http.Error(w, "too many requests", 429) + return + } + w.Write([]byte(content)) + } +} + +// Common handler for both javascript responses. +func (s *Server) HTTPResponse(w http.ResponseWriter, r *http.Request) { + tr := trace.New("http", "response") + defer tr.Finish() + + if !s.ratelimiter.Allowed() { + tr.LazyPrintf("rate limited") + tr.SetError() + http.Error(w, "too many requests", 429) + return + } + + key := keyFromRequest(r) + tr.LazyPrintf("key: %s", key) + op, ok := s.getOp(key) + if !ok { + tr.LazyPrintf("404 error") + tr.SetError() + http.NotFound(w, r) + return + } + + buf := make([]byte, 4*1024) + n, err := r.Body.Read(buf) + + if err != nil && err != io.EOF { + tr.LazyPrintf("400 error reading body: %v", err) + tr.SetError() + http.Error(w, "error reading body", http.StatusBadRequest) + return + } + + op.reply <- buf[:n] + + w.Write([]byte("success")) +} + +func (s *Server) ListenAndServe() { + // Prepare and launch the HTTP server. + r := mux.NewRouter() + r.HandleFunc("/{key}/", s.IndexHandler) + r.HandleFunc("/{key}/response", s.HTTPResponse) + r.HandleFunc("/{key}/u2f_api.js", + s.StaticHandler("u2f_api.js", u2f_api_js)) + r.HandleFunc("/{key}/remoteu2f.js", + s.StaticHandler("remoteu2f.js", remoteu2f_js)) + httpServer := http.Server{ + Addr: s.HTTPAddr, + Handler: r, + } + + go func() { + glog.Infof("HTTP listening on %s", s.HTTPAddr) + err := httpServer.ListenAndServeTLS(s.HTTPCert, s.HTTPKey) + glog.Fatalf("HTTP exiting: %s", err) + }() + + // And now the GRPC server. + lis, err := net.Listen("tcp", s.GRPCAddr) + if err != nil { + glog.Errorf("failed to listen: %v", err) + return + } + + ta, err := credentials.NewServerTLSFromFile(s.GRPCCert, s.GRPCKey) + if err != nil { + glog.Errorf("failed to create TLS transport auth: %v", err) + return + } + + grpcServer := grpc.NewServer(grpc.Creds(ta)) + pb.RegisterRemoteU2FServer(grpcServer, s) + + glog.Infof("GRPC listening on %s", s.GRPCAddr) + err = grpcServer.Serve(lis) + glog.Infof("GRPC exiting: %s", err) +} diff --git a/remoteu2f-proxy/to_embed/authenticate.html b/remoteu2f-proxy/to_embed/authenticate.html new file mode 100644 index 0000000..148fd78 --- /dev/null +++ b/remoteu2f-proxy/to_embed/authenticate.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> + <head> + </head> + + <body> + <h1>remoteu2f - authenticate</h1> + + <h2>{{.Message}}</h2> + + <p class="instructions"> + Please insert/touch your security key to authenticate. + </p> + + <p class="status"> + Status: <span id="status">waiting for security key</span> + </p> + + <script + type="text/javascript" + src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js" + integrity="sha384-8gBf6Y4YYq7Jx97PIqmTwLPin4hxIzQw5aDmUg/DDhul9fFpbbLcLh3nTIIDJKhx" + crossorigin="anonymous"></script> + <script type="text/javascript" src="remoteu2f.js"></script> + <script type="text/javascript" src="u2f_api.js"></script> + <script> + u2f.sign({{.Request}}, handleKeyResponse, 2*60); + </script> + + </body> +</html> diff --git a/remoteu2f-proxy/to_embed/register.html b/remoteu2f-proxy/to_embed/register.html new file mode 100644 index 0000000..e19aa06 --- /dev/null +++ b/remoteu2f-proxy/to_embed/register.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + <head> + </head> + + <body> + <h1>remoteu2f - register</h1> + + <h2>{{.Message}}</h2> + + <p class="instructions"> + Please insert/touch your security key to complete the registration. + </p> + + <p class="status"> + Status: <span id="status">waiting for security key</span> + </p> + + <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js" + integrity="sha384-8gBf6Y4YYq7Jx97PIqmTwLPin4hxIzQw5aDmUg/DDhul9fFpbbLcLh3nTIIDJKhx" + crossorigin="anonymous"></script> + <script type="text/javascript" src="remoteu2f.js"></script> + <script type="text/javascript" src="u2f_api.js"></script> + <script> + u2f.register([{{.Request}}], [], handleKeyResponse, 2*60) + </script> + + </body> +</html> diff --git a/remoteu2f-proxy/to_embed/remoteu2f.js b/remoteu2f-proxy/to_embed/remoteu2f.js new file mode 100644 index 0000000..22718a5 --- /dev/null +++ b/remoteu2f-proxy/to_embed/remoteu2f.js @@ -0,0 +1,25 @@ + +// This function gets called when the security key gives us a response to our +// register/sign request. +function handleKeyResponse(resp) { + if (resp.errorCode != undefined && resp.errorCode != u2f.ErrorCodes.OK) { + codeToText = { + 0: "OK", + 1: "General error (hardware issues?)", + 2: "Bad request (please report)", + 3: "Unsupported configuration (please report)", + 4: "Device ineligible, did you forget to register it?", + 5: "Timed out waiting for security key", + } + + $('#status').text( + codeToText[resp.errorCode] + " -- " + + resp.errorMessage); + return; + } + + $('#status').text('sending response'); + $.post('response', JSON.stringify(resp)).done(function() { + $('#status').text('done'); + }); +} diff --git a/remoteu2f-proxy/to_embed/u2f_api.js b/remoteu2f-proxy/to_embed/u2f_api.js new file mode 100644 index 0000000..dad9f89 --- /dev/null +++ b/remoteu2f-proxy/to_embed/u2f_api.js @@ -0,0 +1,654 @@ +// Copyright 2014-2015 Google Inc. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +// remoteu2f note: Obtained from the reference code at +// https://github.com/google/u2f-ref-code/ + +/** + * @fileoverview The U2F api. + */ + +'use strict'; + +/** Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * The U2F extension id + * @type {string} + * @const + */ +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response' +}; + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + +/** + * A message type for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * signRequests: Array<u2f.SignRequest>, + * registerRequests: ?Array<u2f.RegisterRequest>, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.Request; + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.Response; + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string, + * appId: string + * }} + */ +u2f.RegisterRequest; + +/** + * Data object for a registration response. + * @typedef {{ + * registrationData: string, + * clientData: string + * }} + */ +u2f.RegisterResponse; + + +// Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format a return a sign request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.WrappedChromeRuntimePort_.prototype.formatSignRequest_ = + function(signRequests, timeoutSeconds, reqId) { + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format a return a register request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.WrappedChromeRuntimePort_.prototype.formatRegisterRequest_ = + function(signRequests, registerRequests, timeoutSeconds, reqId) { + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentLocation = /** @type {string} */ (message); + document.location = intentLocation; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + responseObject['requestId'] = this.requestId_; + } + + /* Sign responses from the authenticator do not conform to U2F, + * convert to U2F here. */ + responseObject = this.doResponseFixups_(responseObject); + callback({'data': responseObject}); +}; + +/** + * Fixup the response provided by the Authenticator to conform with + * the U2F spec. + * @param {Object} responseData + * @return {Object} the U2F compliant response object + */ +u2f.WrappedAuthenticatorPort_.prototype.doResponseFixups_ = + function(responseObject) { + if (responseObject.hasOwnProperty('responseData')) { + return responseObject; + } else if (this.requestObject_['type'] != u2f.MessageTypes.U2F_SIGN_REQUEST) { + // Only sign responses require fixups. If this is not a response + // to a sign request, then an internal error has occurred. + return { + 'type': u2f.MessageTypes.U2F_REGISTER_RESPONSE, + 'responseData': { + 'errorCode': u2f.ErrorCodes.OTHER_ERROR, + 'errorMessage': 'Internal error: invalid response from Authenticator' + } + }; + } + + /* Non-conformant sign response, do fixups. */ + var encodedChallengeObject = responseObject['challenge']; + if (typeof encodedChallengeObject !== 'undefined') { + var challengeObject = JSON.parse(atob(encodedChallengeObject)); + var serverChallenge = challengeObject['challenge']; + var challengesList = this.requestObject_['signData']; + var requestChallengeObject = null; + for (var i = 0; i < challengesList.length; i++) { + var challengeObject = challengesList[i]; + if (challengeObject['keyHandle'] == responseObject['keyHandle']) { + requestChallengeObject = challengeObject; + break; + } + } + } + var responseData = { + 'errorCode': responseObject['resultCode'], + 'keyHandle': responseObject['keyHandle'], + 'signatureData': responseObject['signature'], + 'clientData': encodedChallengeObject + }; + return { + 'type': u2f.MessageTypes.U2F_SIGN_RESPONSE, + 'responseData': responseData, + 'requestId': responseObject['requestId'] + } +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Format a return a sign request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds (ignored for now) + * @param {number} reqId + * @return {string} + */ +u2f.WrappedAuthenticatorPort_.prototype.formatSignRequest_ = + function(signRequests, timeoutSeconds, reqId) { + if (!signRequests || signRequests.length == 0) { + return null; + } + /* TODO(fixme): stash away requestId, as the authenticator app does + * not return it for sign responses. */ + this.requestId_ = reqId; + /* TODO(fixme): stash away the signRequests, to deal with the legacy + * response format returned by the Authenticator app. */ + this.requestObject_ = { + 'type': u2f.MessageTypes.U2F_SIGN_REQUEST, + 'signData': signRequests, + 'requestId': reqId, + 'timeout': timeoutSeconds + }; + + var appId = signRequests[0]['appId']; + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.appId=' + encodeURIComponent(appId) + + ';S.eventId=' + reqId + + ';S.challenges=' + + encodeURIComponent( + JSON.stringify(this.getBrowserDataList_(signRequests))) + ';end'; + return intentUrl; +}; + +/** + * Get the browser data objects from the challenge list + * @param {Array} challenges list of challenges + * @return {Array} list of browser data objects + * @private + */ +u2f.WrappedAuthenticatorPort_ + .prototype.getBrowserDataList_ = function(challenges) { + return challenges + .map(function(challenge) { + var browserData = { + 'typ': 'navigator.id.getAssertion', + 'challenge': challenge['challenge'] + }; + var challengeObject = { + 'challenge' : browserData, + 'keyHandle' : challenge['keyHandle'] + }; + return challengeObject; + }); +}; + +/** + * Format a return a register request. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} enrollChallenges + * @param {number} timeoutSeconds (ignored for now) + * @param {number} reqId + * @return {Object} + */ +u2f.WrappedAuthenticatorPort_.prototype.formatRegisterRequest_ = + function(signRequests, enrollChallenges, timeoutSeconds, reqId) { + if (!enrollChallenges || enrollChallenges.length == 0) { + return null; + } + // Assume the appId is the same for all enroll challenges. + var appId = enrollChallenges[0]['appId']; + var registerRequests = []; + for (var i = 0; i < enrollChallenges.length; i++) { + var registerRequest = { + 'challenge': enrollChallenges[i]['challenge'], + 'version': enrollChallenges[i]['version'] + }; + if (enrollChallenges[i]['appId'] != appId) { + // Only include the appId when it differs from the first appId. + registerRequest['appId'] = enrollChallenges[i]['appId']; + } + registerRequests.push(registerRequest); + } + var registeredKeys = []; + if (signRequests) { + for (i = 0; i < signRequests.length; i++) { + var key = { + 'keyHandle': signRequests[i]['keyHandle'], + 'version': signRequests[i]['version'] + }; + // Only include the appId when it differs from the appId that's + // being registered now. + if (signRequests[i]['appId'] != appId) { + key['appId'] = signRequests[i]['appId']; + } + registeredKeys.push(key); + } + } + var request = { + 'type': u2f.MessageTypes.U2F_REGISTER_REQUEST, + 'appId': appId, + 'registerRequests': registerRequests, + 'registeredKeys': registeredKeys, + 'requestId': reqId, + 'timeoutSeconds': timeoutSeconds + }; + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(request)) + + ';end'; + /* TODO(fixme): stash away requestId, this is is not necessary for + * register requests, but here to keep parity with sign. + */ + this.requestId_ = reqId; + return intentUrl; +}; + + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +// High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + * |function((u2f.Error|u2f.SignResponse)))>} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.<u2f.Response>} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {Array<u2f.SignRequest>} signRequests + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(signRequests, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = port.formatSignRequest_(signRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.SignRequest>} signRequests + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(registerRequests, signRequests, + callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = port.formatRegisterRequest_( + signRequests, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; diff --git a/remoteu2f-proxy/tools/embed.go b/remoteu2f-proxy/tools/embed.go new file mode 100644 index 0000000..828e254 --- /dev/null +++ b/remoteu2f-proxy/tools/embed.go @@ -0,0 +1,118 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Flag definitions. +var ( + pkg = flag.String("package", "main", "name of the package to generate") + out = flag.String("out", "embedded_data.go", "file to write to") +) + +func readContent(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + + buf, err := ioutil.ReadAll(f) + return string(buf), err +} + +func escapeContent(c string) string { + // Escape + // ` + // with + // ` + "\x60" + ` + // which let us embed it in the template. + return strings.Replace(c, "`", "` + \"\\x60\" + `", -1) +} + +type File struct { + Name string + Path string + EscapedContent string +} + +const generatedTmpl = ` +package {{.Pkg}} + +// File auto-generated by embed.go. + +{{range .Files}} +// {{.Path}} ----- 8< ----- 8< ----- 8< ----- 8< ----- + +// {{.Name}} contains the content of {{.Path}}. +const {{.Name}} = ` + "`" + `{{.EscapedContent}}` + "`" + ` + +{{end}} + +` + +// tmpl is the compiled version of generatedTmpl above. +var tmpl = template.Must(template.New("generated").Parse(generatedTmpl)) + +func Embed(pkg string, paths []string, outPath string) error { + + var vals struct { + Pkg string + Files []*File + } + + vals.Pkg = pkg + + for _, path := range paths { + f := &File{ + Path: path, + Name: strings.Replace(filepath.Base(path), ".", "_", -1), + } + + content, err := readContent(path) + if err != nil { + return fmt.Errorf("Error reading %q: %v", path, err) + } + + f.EscapedContent = escapeContent(content) + + vals.Files = append(vals.Files, f) + } + + out, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("Error opening %q: %v", *out, err) + } + + err = tmpl.Execute(out, vals) + if err != nil { + return fmt.Errorf("Error executing template: %v", err) + } + + return nil +} + +func main() { + flag.Parse() + + paths := []string{} + for _, glob := range flag.Args() { + matches, err := filepath.Glob(glob) + if err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } + paths = append(paths, matches...) + } + + err := Embed(*pkg, paths, *out) + if err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } +}