Add authentication support

This commit is contained in:
emersion 2017-08-22 00:04:16 +02:00
parent 0fcdcadc0c
commit dac767c4bf
No known key found for this signature in database
GPG Key ID: 0FDE7BE0E88F5E48
4 changed files with 484 additions and 0 deletions

View File

@ -0,0 +1,38 @@
package main
import (
"bufio"
"fmt"
"log"
"os"
"github.com/emersion/hydroxide/protonmail"
)
func main() {
c := &protonmail.Client{
RootURL: "https://dev.protonmail.com/api",
AppVersion: "Web_3.11.1",
ClientID: "Web",
ClientSecret: "4957cc9a2e0a2a49d02475c9d013478d",
}
scanner := bufio.NewScanner(os.Stdin)
fmt.Printf("Username: ")
scanner.Scan()
username := scanner.Text()
fmt.Printf("Password: ")
scanner.Scan()
password := scanner.Text()
fmt.Printf("2FA code: ")
scanner.Scan()
code := scanner.Text()
err := c.Auth(username, password, code, nil)
if err != nil {
log.Fatal(err)
}
}

137
protonmail/auth.go Normal file
View File

@ -0,0 +1,137 @@
package protonmail
import (
"encoding/base64"
"net/http"
"log"
)
type authInfoReq struct {
ClientID string
ClientSecret string
Username string
}
type AuthInfoResp struct {
resp
Version int
TwoFactor int
Modulus string
ServerEphemeral string
Salt string
SRPSession string
}
type AuthInfo struct {
TwoFactor int
version int
modulus string
serverEphemeral string
salt string
srpSession string
}
func (c *Client) AuthInfo(username string) (*AuthInfo, error) {
reqData := &authInfoReq{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Username: username,
}
req, err := c.newJSONRequest(http.MethodPost, "/auth/info", reqData)
if err != nil {
return nil, err
}
var respData AuthInfoResp
if err := c.doJSON(req, &respData); err != nil {
return nil, err
}
return &AuthInfo{
TwoFactor: respData.TwoFactor,
version: respData.Version,
modulus: respData.Modulus,
serverEphemeral: respData.ServerEphemeral,
salt: respData.Salt,
srpSession: respData.SRPSession,
}, nil
}
type authReq struct {
ClientID string
ClientSecret string
Username string
SRPSession string
ClientEphemeral string
ClientProof string
TwoFactorCode string
}
type PasswordMode int
const (
PasswordSingle PasswordMode = 1
PasswordTwo = 2
)
type authResp struct {
resp
AccessToken string
ExpiresIn int
TokenType string
Scope string
UID string `json:"Uid"`
RefreshToken string
EventID string
ServerProof string
PasswordMode PasswordMode
PrivateKey string
KeySalt string
}
func (c *Client) Auth(username, password, twoFactorCode string, info *AuthInfo) error {
if info == nil {
var err error
if info, err = c.AuthInfo(username); err != nil {
return err
}
}
log.Printf("%#v\n", info)
proofs, err := srp([]byte(password), info)
if err != nil {
return err
}
reqData := &authReq{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Username: username,
SRPSession: info.srpSession,
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.clientEphemeral),
ClientProof: base64.StdEncoding.EncodeToString(proofs.clientProof),
TwoFactorCode: twoFactorCode,
}
log.Printf("%#v\n", reqData)
req, err := c.newJSONRequest(http.MethodPost, "/auth", reqData)
if err != nil {
return err
}
var respData authResp
if err := c.doJSON(req, &respData); err != nil {
return err
}
log.Printf("%#v\n", respData)
if err := proofs.VerifyServerProof(respData.ServerProof); err != nil {
return err
}
return nil
}

94
protonmail/protonmail.go Normal file
View File

@ -0,0 +1,94 @@
// Package protonmail implements a ProtonMail API client.
package protonmail
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
)
const Version = 2
const headerAPIVersion = "X-Pm-Apiversion"
type resp struct {
Code int
*apiError
}
func (r *resp) Err() error {
if err := r.apiError; err != nil {
return r.apiError
}
return nil
}
type maybeError interface {
Err() error
}
type apiError struct {
Message string `json:"Error"`
}
func (err apiError) Error() string {
return err.Message
}
// Client is a ProtonMail API client.
type Client struct {
HTTPClient *http.Client
RootURL string
AppVersion string
ClientID string
ClientSecret string
}
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, c.RootURL + path, body)
if err != nil {
return nil, err
}
req.Header.Set("X-Pm-Appversion", c.AppVersion)
req.Header.Set(headerAPIVersion, strconv.Itoa(Version))
return req, nil
}
func (c *Client) newJSONRequest(method, path string, body interface{}) (*http.Request, error) {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil {
return nil, err
}
return c.newRequest(method, path, &b)
}
func (c *Client) do(req *http.Request) (*http.Response, error) {
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return httpClient.Do(req)
}
func (c *Client) doJSON(req *http.Request, respData interface{}) error {
resp, err := c.do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(respData); err != nil {
return err
}
if maybeError, ok := respData.(maybeError); ok {
if err := maybeError.Err(); err != nil {
return err
}
}
return nil
}

215
protonmail/srp.go Normal file
View File

@ -0,0 +1,215 @@
package protonmail
import (
"bytes"
"crypto/rand"
"crypto/sha512"
"crypto/subtle"
"encoding/base64"
"errors"
"io"
"math/big"
"github.com/emersion/go-bcrypt"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/clearsign"
openpgperrors "golang.org/x/crypto/openpgp/errors"
)
var randReader io.Reader = rand.Reader
func decodeModulus(msg string) ([]byte, error) {
block, _ := clearsign.Decode([]byte(msg))
if block == nil {
return nil, errors.New("invalid modulus signed PGP block")
}
// TODO: check signature key
_, err := openpgp.CheckDetachedSignature(nil, bytes.NewReader(block.Plaintext), block.ArmoredSignature.Body)
if err != nil && err != openpgperrors.ErrUnknownIssuer {
return nil, err
}
return base64.StdEncoding.DecodeString(string(block.Plaintext))
}
func expandHash(b []byte) []byte {
var expanded []byte
var part [64]byte
part = sha512.Sum512(append(b, 0))
expanded = append(expanded, part[:]...)
part = sha512.Sum512(append(b, 1))
expanded = append(expanded, part[:]...)
part = sha512.Sum512(append(b, 2))
expanded = append(expanded, part[:]...)
part = sha512.Sum512(append(b, 3))
expanded = append(expanded, part[:]...)
return expanded
}
func hashPassword(version int, password, salt, modulus []byte) ([]byte, error) {
switch version {
case 3, 4:
salt = append(salt, []byte("proton")...)
hashed, err := bcrypt.GenerateFromPasswordAndSalt(password, 10, salt)
if err != nil {
return nil, err
}
hashed = bytes.Replace(hashed, []byte("$2a$"), []byte("$2y$"), 1)
return expandHash(append([]byte(hashed), modulus...)), nil
default:
return nil, errors.New("unsupported auth version")
}
}
func reverse(b []byte) {
for i := 0; i < len(b)/2; i++ {
j := len(b) - 1 - i
b[i], b[j] = b[j], b[i]
}
}
func itoa(i *big.Int, l int) []byte {
b := i.Bytes()
reverse(b)
padding := make([]byte, l/8 - len(b))
b = append(b, padding...)
return b
}
func atoi(b []byte) *big.Int {
reverse(b)
return big.NewInt(0).SetBytes(b)
}
type proofs struct {
clientEphemeral []byte
clientProof []byte
expectedServerProof []byte
}
// From https://github.com/ProtonMail/WebClient/blob/public/src/app/authentication/services/srp.js#L13
func generateProofs(l int, hash func([]byte) []byte, modulusBytes, hashedBytes, serverEphemeralBytes []byte) (*proofs, error) {
generator := big.NewInt(2)
multiplier := atoi(hash(append(itoa(generator, l), modulusBytes...)))
modulus := atoi(modulusBytes)
hashed := atoi(hashedBytes)
serverEphemeral := atoi(serverEphemeralBytes)
modulusMinusOne := big.NewInt(0).Sub(modulus, big.NewInt(1))
if modulus.BitLen() != l {
return nil, errors.New("SRP modulus has incorrect size")
}
multiplier = multiplier.Mod(multiplier, modulus)
if multiplier.Cmp(big.NewInt(1)) <= 0 || multiplier.Cmp(modulusMinusOne) >= 0 {
return nil, errors.New("SRP multiplier is out of bounds")
}
if generator.Cmp(big.NewInt(1)) <= 0 || generator.Cmp(modulusMinusOne) >= 0 {
return nil, errors.New("SRP generator is out of bounds")
}
if serverEphemeral.Cmp(big.NewInt(1)) <= 0 || serverEphemeral.Cmp(modulusMinusOne) >= 0 {
return nil, errors.New("SRP server ephemeral is out of bounds")
}
// TODO: Check primality
// TODO: Check safe primality
var clientSecret, clientEphemeral, scramblingParam *big.Int
for {
for {
var err error
clientSecret, err = rand.Int(randReader, modulusMinusOne)
if err != nil {
return nil, err
}
if clientSecret.Cmp(big.NewInt(int64(l)*2)) <= 0 { // Very unlikely
continue
}
break
}
clientEphemeral = big.NewInt(0).Exp(generator, clientSecret, modulus)
scramblingParam = atoi(hash(append(itoa(clientEphemeral, l), itoa(serverEphemeral, l)...)))
if scramblingParam.Cmp(big.NewInt(0)) == 0 { // Very unlikely
continue
}
break
}
subtracted := big.NewInt(0).Sub(serverEphemeral, big.NewInt(0).Mod(big.NewInt(0).Mul(big.NewInt(0).Exp(generator, hashed, modulus), multiplier), modulus))
if subtracted.Cmp(big.NewInt(0)) < 0 {
subtracted.Add(subtracted, modulus)
}
exponent := big.NewInt(0).Mod(big.NewInt(0).Add(big.NewInt(0).Mul(scramblingParam, hashed), clientSecret), modulusMinusOne)
sharedSession := big.NewInt(0).Exp(subtracted, exponent, modulus)
var clientProof []byte
clientProof = append(clientProof, itoa(clientEphemeral, l)...)
clientProof = append(clientProof, itoa(serverEphemeral, l)...)
clientProof = append(clientProof, itoa(sharedSession, l)...)
clientProof = hash(clientProof)
var serverProof []byte
serverProof = append(serverProof, itoa(clientEphemeral, l)...)
serverProof = append(serverProof, clientProof...)
serverProof = append(serverProof, itoa(sharedSession, l)...)
serverProof = hash(serverProof)
return &proofs{
clientEphemeral: itoa(clientEphemeral, l),
clientProof: clientProof,
expectedServerProof: serverProof,
}, nil
}
func (p *proofs) VerifyServerProof(serverProofString string) error {
serverProof, err := base64.StdEncoding.DecodeString(serverProofString)
if err != nil {
return err
}
if subtle.ConstantTimeCompare(p.expectedServerProof, serverProof) != 1 {
return errors.New("invalid server proof")
}
return nil
}
// From https://github.com/ProtonMail/WebClient/blob/public/src/app/authentication/services/srp.js#L135
func srp(password []byte, info *AuthInfo) (*proofs, error) {
modulus, err := decodeModulus(info.modulus)
if err != nil {
return nil, err
}
serverEphemeral, err := base64.StdEncoding.DecodeString(info.serverEphemeral)
if err != nil {
return nil, err
}
salt, err := base64.StdEncoding.DecodeString(info.salt)
if err != nil {
return nil, err
}
hashed, err := hashPassword(info.version, password, salt, modulus)
if err != nil {
return nil, err
}
proofs, err := generateProofs(2048, expandHash, modulus, hashed, serverEphemeral)
if err != nil {
return nil, err
}
return proofs, nil
}