Add support for client certificates

This commit is contained in:
Ken-Håvard Lieng 2016-01-11 21:04:57 +01:00
parent d9b63dd0ef
commit 937560e859
20 changed files with 376 additions and 39 deletions

File diff suppressed because one or more lines are too long

View File

@ -96,7 +96,7 @@ gulp.task('dev', ['html', 'css', 'fonts', 'config', 'gzip:dev', 'bindata:dev'],
res.sendFile(path.join(__dirname, 'dist', 'index.html')); res.sendFile(path.join(__dirname, 'dist', 'index.html'));
}); });
app.listen(3000, 'localhost', function (err) { app.listen(3000, function (err) {
if (err) { if (err) {
console.log(err); console.log(err);
return; return;

View File

@ -38,6 +38,7 @@
"dependencies": { "dependencies": {
"autolinker": "^0.22.0", "autolinker": "^0.22.0",
"backo": "^1.1.0", "backo": "^1.1.0",
"base64-arraybuffer": "^0.1.5",
"eventemitter2": "^0.4.14", "eventemitter2": "^0.4.14",
"history": "^1.17.0", "history": "^1.17.0",
"immutable": "^3.7.6", "immutable": "^3.7.6",

View File

@ -9,6 +9,15 @@ body {
background: #f0f0f0; background: #f0f0f0;
} }
h1, h2, h3, h4, h5, h6 {
font-family: Montserrat, sans-serif;
font-weight: 400;
}
h1 {
font-weight: 700;
}
input { input {
font: 16px Droid Sans Mono, monospace; font: 16px Droid Sans Mono, monospace;
border: none; border: none;
@ -134,14 +143,13 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
bottom: 0; bottom: 0;
} }
.connect .navicon { .connect .navicon, .settings .navicon {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
} }
.connect-form h1 { .connect-form h1 {
font: 32px Montserrat, sans-serif;
margin-bottom: 15px; margin-bottom: 15px;
text-align: center; text-align: center;
} }
@ -406,6 +414,58 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
background: #DDD; background: #DDD;
} }
.settings {
text-align: center;
}
.settings p {
color: #999;
}
.settings h1 {
margin: 20px;
}
.settings h2 {
margin: 15px;
}
.settings button {
margin: 5px;
color: #FFF;
background: #6BB758;
padding: 10px 20px;
width: 200px;
}
.settings button:hover {
background: #7BBF6A;
}
.settings button:active {
background: #6BB758;
}
.settings div {
display: inline-block;
}
.settings .error {
margin: 10px;
color: #F6546A;
}
.input-file {
color: #FFF;
background: #222 !important;
padding: 10px;
margin: 5px;
width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.tablist { .tablist {
width: 200px; width: 200px;

View File

@ -6,7 +6,7 @@
<title>Dispatch</title> <title>Dispatch</title>
<link href="//fonts.googleapis.com/css?family=Montserrat|Droid+Sans+Mono" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Droid+Sans+Mono|Montserrat:400,700" rel="stylesheet">
<link href="/bundle.css" rel="stylesheet"> <link href="/bundle.css" rel="stylesheet">
</head> </head>
<body> <body>

View File

@ -18,8 +18,13 @@ export const PART = 'PART';
export const SEARCH_MESSAGES = 'SEARCH_MESSAGES'; export const SEARCH_MESSAGES = 'SEARCH_MESSAGES';
export const SELECT_TAB = 'SELECT_TAB'; export const SELECT_TAB = 'SELECT_TAB';
export const SEND_MESSAGE = 'SEND_MESSAGE'; export const SEND_MESSAGE = 'SEND_MESSAGE';
export const SET_CERT = 'SET_CERT';
export const SET_CERT_ERROR = 'SET_CERT_ERROR';
export const SET_ENVIRONMENT = 'SET_ENVIRONMENT'; export const SET_ENVIRONMENT = 'SET_ENVIRONMENT';
export const SET_KEY = 'SET_KEY';
export const SET_NICK = 'SET_NICK'; export const SET_NICK = 'SET_NICK';
export const SOCKET_CERT_FAIL = 'SOCKET_CERT_FAIL';
export const SOCKET_CERT_SUCCESS = 'SOCKET_CERT_SUCCESS';
export const SOCKET_CHANNELS = 'SOCKET_CHANNELS'; export const SOCKET_CHANNELS = 'SOCKET_CHANNELS';
export const SOCKET_JOIN = 'SOCKET_JOIN'; export const SOCKET_JOIN = 'SOCKET_JOIN';
export const SOCKET_MESSAGE = 'SOCKET_MESSAGE'; export const SOCKET_MESSAGE = 'SOCKET_MESSAGE';
@ -34,4 +39,5 @@ export const SOCKET_USERS = 'SOCKET_USERS';
export const TAB_HISTORY_POP = 'TAB_HISTORY_POP'; export const TAB_HISTORY_POP = 'TAB_HISTORY_POP';
export const TOGGLE_MENU = 'TOGGLE_MENU'; export const TOGGLE_MENU = 'TOGGLE_MENU';
export const TOGGLE_SEARCH = 'TOGGLE_SEARCH'; export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
export const UPLOAD_CERT = 'UPLOAD_CERT';
export const WHOIS = 'WHOIS'; export const WHOIS = 'WHOIS';

View File

@ -0,0 +1,45 @@
import base64 from 'base64-arraybuffer';
import * as actions from '../actions';
export function setCertError(message) {
return {
type: actions.SET_CERT_ERROR,
message
};
}
export function uploadCert() {
return (dispatch, getState) => {
const { settings } = getState();
if (settings.has('cert') && settings.has('key')) {
dispatch({
type: actions.UPLOAD_CERT,
socket: {
type: 'cert',
data: {
cert: settings.get('cert'),
key: settings.get('key')
}
}
});
} else {
dispatch(setCertError('Missing certificate or key'));
}
};
}
export function setCert(fileName, cert) {
return {
type: actions.SET_CERT,
fileName,
cert: base64.encode(cert)
};
}
export function setKey(fileName, key) {
return {
type: actions.SET_KEY,
fileName,
key: base64.encode(key)
};
}

View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
@pure
export default class FileInput extends Component {
componentWillMount() {
this.input = window.document.createElement('input');
this.input.setAttribute('type', 'file');
this.input.addEventListener('change', e => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = () => {
console.log(reader.result.byteLength);
this.props.onChange(file.name, reader.result);
};
reader.readAsArrayBuffer(file);
});
}
handleClick = () => this.input.click();
render() {
return (
<button className="input-file" onClick={this.handleClick}>{this.props.name}</button>
);
}
}

View File

@ -1,14 +0,0 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
import Navicon from './Navicon';
@pure
export default class Settings extends Component {
render() {
return (
<div>
<Navicon />
</div>
);
}
}

View File

@ -0,0 +1,47 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import pure from 'pure-render-decorator';
import Navicon from '../components/Navicon';
import FileInput from '../components/FileInput';
import { setCert, setKey, uploadCert } from '../actions/settings';
@pure
class Settings extends Component {
handleCertChange = (name, data) => this.props.dispatch(setCert(name, data));
handleKeyChange = (name, data) => this.props.dispatch(setKey(name, data));
handleCertUpload = () => this.props.dispatch(uploadCert());
render() {
const { settings } = this.props;
const status = settings.get('uploadingCert') ? 'Uploading...' : 'Upload';
const error = settings.get('certError');
return (
<div className="settings">
<Navicon />
<h1>Settings</h1>
<h2>Client Certificate</h2>
<div>
<p>Certificate</p>
<FileInput
name={settings.get('certFile') || 'Select Certificate'}
onChange={this.handleCertChange}
/>
</div>
<div>
<p>Private Key</p>
<FileInput
name={settings.get('keyFile') || 'Select Key'}
onChange={this.handleKeyChange}
/>
</div>
<button onClick={this.handleCertUpload}>{status}</button>
{ error ? <p className="error">{error}</p> : null }
</div>
);
}
}
export default connect(state => ({
settings: state.settings
}))(Settings);

View File

@ -7,6 +7,7 @@ import messages from './messages';
import privateChats from './privateChats'; import privateChats from './privateChats';
import search from './search'; import search from './search';
import servers from './servers'; import servers from './servers';
import settings from './settings';
import showMenu from './showMenu'; import showMenu from './showMenu';
import tab from './tab'; import tab from './tab';
@ -19,6 +20,7 @@ export default combineReducers({
privateChats, privateChats,
search, search,
servers, servers,
settings,
showMenu, showMenu,
tab tab
}); });

View File

@ -0,0 +1,41 @@
import { Map } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
export default createReducer(Map(), {
[actions.UPLOAD_CERT](state) {
return state.set('uploadingCert', true);
},
[actions.SOCKET_CERT_SUCCESS]() {
return Map({ uploadingCert: false });
},
[actions.SOCKET_CERT_FAIL](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT_ERROR](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT](state, action) {
return state.merge({
certFile: action.fileName,
cert: action.cert
});
},
[actions.SET_KEY](state, action) {
return state.merge({
keyFile: action.fileName,
key: action.key
});
}
});

View File

@ -3,7 +3,7 @@ import { Route, IndexRoute } from 'react-router';
import App from './containers/App'; import App from './containers/App';
import Connect from './containers/Connect'; import Connect from './containers/Connect';
import Chat from './containers/Chat'; import Chat from './containers/Chat';
import Settings from './components/Settings'; import Settings from './containers/Settings';
export default function createRoutes() { export default function createRoutes() {
return ( return (
@ -13,7 +13,7 @@ export default function createRoutes() {
<Route path="/:server" component={Chat} /> <Route path="/:server" component={Chat} />
<Route path="/:server/:channel" component={Chat} /> <Route path="/:server/:channel" component={Chat} />
<Route path="/:server/pm/:user" component={Chat} /> <Route path="/:server/pm/:user" component={Chat} />
<IndexRoute component={Settings} /> <IndexRoute component={null} />
</Route> </Route>
); );
} }

View File

@ -49,20 +49,24 @@ func (c *Client) connect() error {
if c.TLS { if c.TLS {
if c.TLSConfig == nil { if c.TLSConfig == nil {
c.TLSConfig = &tls.Config{InsecureSkipVerify: true} c.TLSConfig = &tls.Config{
InsecureSkipVerify: true,
}
} }
if conn, err := tls.DialWithDialer(c.dialer, "tcp", c.Server, c.TLSConfig); err != nil { conn, err := tls.DialWithDialer(c.dialer, "tcp", c.Server, c.TLSConfig)
if err != nil {
return err return err
} else {
c.conn = conn
} }
c.conn = conn
} else { } else {
if conn, err := c.dialer.Dial("tcp", c.Server); err != nil { conn, err := c.dialer.Dial("tcp", c.Server)
if err != nil {
return err return err
} else {
c.conn = conn
} }
c.conn = conn
} }
c.connected = true c.connected = true

View File

@ -1,6 +1,8 @@
package server package server
import ( import (
"crypto/tls"
"github.com/khlieng/dispatch/irc" "github.com/khlieng/dispatch/irc"
"github.com/khlieng/dispatch/storage" "github.com/khlieng/dispatch/storage"
) )
@ -20,6 +22,12 @@ func reconnectIRC() {
i.Password = server.Password i.Password = server.Password
i.Realname = server.Realname i.Realname = server.Realname
if user.Certificate != nil {
i.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{*user.Certificate},
}
}
i.Connect(server.Address) i.Connect(server.Address)
session.setIRC(i.Host, i) session.setIRC(i.Host, i)
go newIRCHandler(i, session).run() go newIRCHandler(i, session).run()

View File

@ -127,6 +127,11 @@ type SearchResult struct {
Results []storage.Message `json:"results"` Results []storage.Message `json:"results"`
} }
type ClientCert struct {
Cert []byte `json:"cert"`
Key []byte `json:"key"`
}
type Error struct { type Error struct {
Server string `json:"server"` Server string `json:"server"`
Message string `json:"message"` Message string `json:"message"`

View File

@ -1,6 +1,7 @@
package server package server
import ( import (
"crypto/tls"
"encoding/json" "encoding/json"
"log" "log"
"strings" "strings"
@ -104,6 +105,12 @@ func (h *wsHandler) connect(b []byte) {
i.Password = data.Password i.Password = data.Password
i.Realname = data.Realname i.Realname = data.Realname
if h.session.user.Certificate != nil {
i.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{*h.session.user.Certificate},
}
}
if idx := strings.Index(data.Server, ":"); idx < 0 { if idx := strings.Index(data.Server, ":"); idx < 0 {
h.session.setIRC(data.Server, i) h.session.setIRC(data.Server, i)
} else { } else {
@ -231,6 +238,19 @@ func (h *wsHandler) search(b []byte) {
}() }()
} }
func (h *wsHandler) cert(b []byte) {
var data ClientCert
json.Unmarshal(b, &data)
err := h.session.user.SetCertificate(data.Cert, data.Key)
if err != nil {
h.session.sendJSON("cert_fail", Error{Message: err.Error()})
return
}
h.session.sendJSON("cert_success", nil)
}
func (h *wsHandler) initHandlers() { func (h *wsHandler) initHandlers() {
h.handlers = map[string]func([]byte){ h.handlers = map[string]func([]byte){
"connect": h.connect, "connect": h.connect,
@ -244,5 +264,6 @@ func (h *wsHandler) initHandlers() {
"whois": h.whois, "whois": h.whois,
"away": h.away, "away": h.away,
"search": h.search, "search": h.search,
"cert": h.cert,
} }
} }

View File

@ -33,6 +33,22 @@ func (d directory) Index(userID string) string {
return filepath.Join(d.Logs(), userID+".idx") return filepath.Join(d.Logs(), userID+".idx")
} }
func (d directory) Users() string {
return filepath.Join(d.Root(), "users")
}
func (d directory) User(userID string) string {
return filepath.Join(d.Users(), userID)
}
func (d directory) Certificate(userID string) string {
return filepath.Join(d.User(userID), "cert.pem")
}
func (d directory) Key(userID string) string {
return filepath.Join(d.User(userID), "key.pem")
}
func (d directory) Config() string { func (d directory) Config() string {
return filepath.Join(d.Root(), "config.toml") return filepath.Join(d.Root(), "config.toml")
} }

View File

@ -2,10 +2,12 @@ package storage
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/json" "encoding/json"
"log" "log"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/khlieng/dispatch/Godeps/_workspace/src/github.com/blevesearch/bleve" "github.com/khlieng/dispatch/Godeps/_workspace/src/github.com/blevesearch/bleve"
@ -39,10 +41,12 @@ type Message struct {
} }
type User struct { type User struct {
UUID string UUID string
Certificate *tls.Certificate `json:"-"`
messageLog *bolt.DB messageLog *bolt.DB
messageIndex bleve.Index messageIndex bleve.Index
lock sync.Mutex
} }
func NewUser(uuid string) *User { func NewUser(uuid string) *User {
@ -73,6 +77,7 @@ func LoadUsers() []*User {
b.ForEach(func(k, v []byte) error { b.ForEach(func(k, v []byte) error {
user := User{UUID: string(k)} user := User{UUID: string(k)}
user.openMessageLog() user.openMessageLog()
user.loadCertificate()
users = append(users, &user) users = append(users, &user)

60
storage/user_cert.go Normal file
View File

@ -0,0 +1,60 @@
package storage
import (
"crypto/tls"
"errors"
"io/ioutil"
"os"
)
var (
ErrInvalidCert = errors.New("Invalid certificate")
ErrCouldNotSaveCert = errors.New("Could not save certificate")
)
func (u *User) SetCertificate(certPEM, keyPEM []byte) error {
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return ErrInvalidCert
}
u.lock.Lock()
u.Certificate = &cert
u.lock.Unlock()
err = os.MkdirAll(Path.User(u.UUID), 0700)
if err != nil {
return ErrCouldNotSaveCert
}
err = ioutil.WriteFile(Path.Certificate(u.UUID), certPEM, 0600)
if err != nil {
return ErrCouldNotSaveCert
}
err = ioutil.WriteFile(Path.Key(u.UUID), keyPEM, 0600)
if err != nil {
return ErrCouldNotSaveCert
}
return nil
}
func (u *User) loadCertificate() error {
certPEM, err := ioutil.ReadFile(Path.Certificate(u.UUID))
if err != nil {
return err
}
keyPEM, err := ioutil.ReadFile(Path.Key(u.UUID))
if err != nil {
return err
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return err
}
u.Certificate = &cert
return nil
}