Add authentication support
This commit is contained in:
parent
0fcdcadc0c
commit
dac767c4bf
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue