Support account recovery with a recovery key

signal-cli 0.14.9 can recover an existing account with the Recovery
Key shown by Signal Android, which is the only way to register an
account without a phone number. Add recovery_key, totp and reregister
to the register request, for both the CLI and the JSON-RPC mode.

Registering by ACI without a recovery key, and verifying by ACI, are
rejected up front with a clear error, as there is no phone number to
send a verification code to.
This commit is contained in:
joda 2026-10-27 04:01:04 +02:00
parent 6e2d3b94d6
commit 9e3ec82811
No known key found for this signature in database
GPG Key ID: A74B8E88FEAC7850
5 changed files with 84 additions and 13 deletions

View File

@ -55,3 +55,20 @@ func TestAccountIdentifierFromParam(t *testing.T) {
}
})
}
// An ACI has no phone number to send a code to, so it can only be registered with a recovery key and never verified.
func TestRegisterByACIRequiresRecoveryKey(t *testing.T) {
gin.SetMode(gin.TestMode)
aci := gin.Params{{Key: "number", Value: "11111111-1111-4111-8111-111111111111"}, {Key: "token", Value: "123456"}}
for name, handler := range map[string]gin.HandlerFunc{"register": NewApi(nil).RegisterNumber, "verify": NewApi(nil).VerifyRegisteredNumber} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = aci
c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
handler(c)
if w.Code != 400 || !strings.Contains(w.Body.String(), "recovery_key") {
t.Fatalf("%s: status = %d, body %s", name, w.Code, w.Body.String())
}
}
}

View File

@ -84,8 +84,11 @@ type Configuration struct {
}
type RegisterNumberRequest struct {
UseVoice bool `json:"use_voice,omitempty"`
Captcha string `json:"captcha,omitempty"`
UseVoice bool `json:"use_voice,omitempty"`
Captcha string `json:"captcha,omitempty"`
RecoveryKey string `json:"recovery_key,omitempty"` // 64-character Recovery Key shown by Signal Android. Recovers the account without SMS verification.
Totp string `json:"totp,omitempty"` // Six-digit TOTP token, only needed if recovery asks for it.
Reregister bool `json:"reregister,omitempty"` // Register even if the account is still registered locally.
}
type UnregisterNumberRequest struct {
@ -290,7 +293,7 @@ func (a *Api) About(c *gin.Context) {
// @Summary Register a phone number.
// @Tags Devices
// @Description Register a phone number with the signal network.
// @Description Register a phone number with the signal network. An existing account can instead be recovered with its Recovery Key (recovery_key), in which case no verification is needed. Accounts without a phone number can only be recovered this way, using their ACI (Account Key) as identifier. If the response asks for a TOTP token, repeat the request with totp.
// @Accept json
// @Produce json
// @Success 201
@ -299,7 +302,7 @@ func (a *Api) About(c *gin.Context) {
// @Param data body RegisterNumberRequest false "Additional Settings"
// @Router /v1/register/{number} [post]
func (a *Api) RegisterNumber(c *gin.Context) {
_, number, err := accountIdentifierFromParam(c)
typ, number, err := accountIdentifierFromParam(c)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
@ -326,7 +329,13 @@ func (a *Api) RegisterNumber(c *gin.Context) {
return
}
err = a.signalClient.RegisterNumber(number, req.UseVoice, req.Captcha)
// without a phone number there is nothing to send a verification code to
if typ == utils.IdentifierACI && req.RecoveryKey == "" {
c.JSON(400, Error{Msg: "Couldn't process request - registering by ACI requires a recovery_key"})
return
}
err = a.signalClient.RegisterNumber(number, req.UseVoice, req.Captcha, req.RecoveryKey, req.Totp, req.Reregister)
if err != nil {
switch err.(type) {
case *client.InvalidTransportError:
@ -430,11 +439,15 @@ func (a *Api) DeleteLocalAccountData(c *gin.Context) {
// @Param token path string true "Verification Code"
// @Router /v1/register/{number}/verify/{token} [post]
func (a *Api) VerifyRegisteredNumber(c *gin.Context) {
_, number, err := accountIdentifierFromParam(c)
typ, number, err := accountIdentifierFromParam(c)
if err != nil {
c.JSON(400, Error{Msg: err.Error()})
return
}
if typ == utils.IdentifierACI {
c.JSON(400, Error{Msg: "Couldn't process request - only phone numbers can be verified, accounts registered with a recovery_key need no verification"})
return
}
token := c.Param("token")
pin := ""

View File

@ -774,16 +774,21 @@ func (s *SignalClient) About() About {
return about
}
func (s *SignalClient) RegisterNumber(number string, useVoice bool, captcha string) error {
// RegisterNumber registers an account. With a recoveryKey an existing account is recovered instead
// (no verification needed), which is the only way to register an account without a phone number.
func (s *SignalClient) RegisterNumber(number string, useVoice bool, captcha string, recoveryKey string, totp string, reregister bool) error {
var err error
var jsonRpc2Client *JsonRpc2Client
if s.signalCliMode == JsonRpc {
type Request struct {
UseVoice bool `json:"voice,omitempty"`
Captcha string `json:"captcha,omitempty"`
Account string `json:"account,omitempty"`
UseVoice bool `json:"voice,omitempty"`
Captcha string `json:"captcha,omitempty"`
Account string `json:"account,omitempty"`
RecoveryKey string `json:"recoveryKey,omitempty"`
Totp string `json:"totp,omitempty"`
Reregister bool `json:"reregister,omitempty"`
}
request := Request{Account: number}
request := Request{Account: number, RecoveryKey: recoveryKey, Totp: totp, Reregister: reregister}
if useVoice {
request.UseVoice = useVoice
@ -809,6 +814,18 @@ func (s *SignalClient) RegisterNumber(number string, useVoice bool, captcha stri
command = append(command, []string{"--captcha", captcha}...)
}
if recoveryKey != "" {
command = append(command, []string{"--recovery-key", recoveryKey}...)
}
if totp != "" {
command = append(command, []string{"--totp", totp}...)
}
if reregister {
command = append(command, "--reregister")
}
_, err = s.cliClient.Execute(true, command, "")
}

View File

@ -282,6 +282,18 @@ const docTemplate = `{
"captcha": {
"type": "string"
},
"recovery_key": {
"description": "64-character Recovery Key shown by Signal Android. Recovers the account without SMS verification.",
"type": "string"
},
"reregister": {
"description": "Register even if the account is still registered locally.",
"type": "boolean"
},
"totp": {
"description": "Six-digit TOTP token, only needed if recovery asks for it.",
"type": "string"
},
"use_voice": {
"type": "boolean"
}
@ -4656,7 +4668,7 @@ const docTemplate = `{
"consumes": [
"application/json"
],
"description": "Register a phone number with the signal network.",
"description": "Register a phone number with the signal network. An existing account can instead be recovered with its Recovery Key (recovery_key), in which case no verification is needed. Accounts without a phone number can only be recovered this way, using their ACI (Account Key) as identifier. If the response asks for a TOTP token, repeat the request with totp.",
"parameters": [
{
"description": "Account identifier (E.164 phone number or ACI UUID)",

View File

@ -277,6 +277,18 @@
"captcha": {
"type": "string"
},
"recovery_key": {
"description": "64-character Recovery Key shown by Signal Android. Recovers the account without SMS verification.",
"type": "string"
},
"reregister": {
"description": "Register even if the account is still registered locally.",
"type": "boolean"
},
"totp": {
"description": "Six-digit TOTP token, only needed if recovery asks for it.",
"type": "string"
},
"use_voice": {
"type": "boolean"
}
@ -4651,7 +4663,7 @@
"consumes": [
"application/json"
],
"description": "Register a phone number with the signal network.",
"description": "Register a phone number with the signal network. An existing account can instead be recovered with its Recovery Key (recovery_key), in which case no verification is needed. Accounts without a phone number can only be recovered this way, using their ACI (Account Key) as identifier. If the response asks for a TOTP token, repeat the request with totp.",
"parameters": [
{
"description": "Account identifier (E.164 phone number or ACI UUID)",