Merge branch 'cross-signing'

This commit is contained in:
Tulir Asokan
2020-11-21 14:43:27 +02:00
12 changed files with 623 additions and 87 deletions

View File

@ -104,19 +104,23 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"e": {"edit"},
"dl": {"download"},
"o": {"open"},
"4s": {"ssss"},
"s4": {"ssss"},
"cs": {"cross-signing"},
},
autocompleters: map[string]CommandAutocompleter{
"devices": autocompleteDevice,
"device": autocompleteDevice,
"verify": autocompleteDevice,
"unverify": autocompleteDevice,
"blacklist": autocompleteDevice,
"upload": autocompleteFile,
"download": autocompleteFile,
"open": autocompleteFile,
"import": autocompleteFile,
"export": autocompleteFile,
"export-room": autocompleteFile,
"devices": autocompleteUser,
"device": autocompleteDevice,
"verify": autocompleteUser,
"verify-device": autocompleteDevice,
"unverify": autocompleteDevice,
"blacklist": autocompleteDevice,
"upload": autocompleteFile,
"download": autocompleteFile,
"open": autocompleteFile,
"import": autocompleteFile,
"export": autocompleteFile,
"export-room": autocompleteFile,
},
commands: map[string]CommandHandler{
"unknown-command": cmdUnknownCommand,
@ -164,6 +168,7 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"fingerprint": cmdFingerprint,
"devices": cmdDevices,
"verify-device": cmdVerifyDevice,
"verify": cmdVerify,
"device": cmdDevice,
"unverify": cmdUnverify,
@ -172,6 +177,8 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"import": cmdImportKeys,
"export": cmdExportKeys,
"export-room": cmdExportRoomKeys,
"ssss": cmdSSSS,
"cross-signing": cmdCrossSigning,
},
}
}

View File

@ -19,6 +19,7 @@
package ui
import (
"errors"
"fmt"
"io/ioutil"
"path/filepath"
@ -26,7 +27,10 @@ import (
"time"
"unicode"
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/ssss"
"maunium.net/go/mautrix/id"
)
@ -77,15 +81,20 @@ func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string,
return
}
func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) {
if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) {
return autocompleteDeviceUserID(cmd)
}
return []string{}, ""
}
func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) {
if len(cmd.Args) == 0 {
return []string{}, ""
} else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) {
return autocompleteDeviceUserID(cmd)
} else if cmd.Command != "devices" {
return autocompleteDeviceDeviceID(cmd)
}
return []string{}, ""
return autocompleteDeviceDeviceID(cmd)
}
func getDevice(cmd *Command) *crypto.DeviceIdentity {
@ -134,7 +143,11 @@ func cmdDevices(cmd *Command) {
}
var buf strings.Builder
for _, device := range devices {
_, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, device.Trust.String(), device.Fingerprint())
trust := device.Trust.String()
if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
trust = "verified (transitive)"
}
_, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint())
}
resp := buf.String()
cmd.Reply("%s", resp[:len(resp)-1])
@ -149,13 +162,28 @@ func cmdDevice(cmd *Command) {
if device.Deleted {
deviceType = "Deleted device"
}
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
trustState := device.Trust.String()
if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
trustState = "verified (transitive)"
}
cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s",
deviceType, device.DeviceID, device.UserID,
device.Fingerprint(), device.IdentityKey,
device.Name, device.Trust.String())
device.Name, trustState)
}
func cmdVerify(cmd *Command) {
func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) {
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
err := mach.SignOwnDevice(device)
if err != nil {
cmd.Reply("Failed to upload cross-signing signature: %v", err)
} else {
cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name)
}
}
func cmdVerifyDevice(cmd *Command) {
device := getDevice(cmd)
if device == nil {
return
@ -184,11 +212,49 @@ func cmdVerify(cmd *Command) {
if device.Trust == crypto.TrustStateBlacklisted {
action = "unblacklisted and verified"
}
device.Trust = crypto.TrustStateVerified
putDevice(cmd, device, action)
if device.UserID == cmd.Matrix.Client().UserID {
crossSignDevice(cmd, device)
device.Trust = crypto.TrustStateVerified
putDevice(cmd, device, action)
} else {
putDevice(cmd, device, action)
cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing")
}
}
}
func cmdVerify(cmd *Command) {
if len(cmd.Args) < 1 {
cmd.Reply("Usage: /%s <user ID> [--force]", cmd.OrigCommand)
return
}
force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force"
userID := id.UserID(cmd.Args[0])
room := cmd.Room.Room
if !room.Encrypted {
cmd.Reply("In-room verification is only supported in encrypted rooms")
return
}
if (!room.IsDirect || room.OtherUser != userID) && !force {
cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+
"or use `--force` to start the verification anyway.", userID)
return
}
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
if mach.CrossSigningKeys == nil && !force {
cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " +
"or use `--force` to start the verification anyway")
return
}
modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout)
_, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second)
if err != nil {
cmd.Reply("Failed to start in-room verification: %v", err)
return
}
cmd.MainView.ShowModal(modal)
}
func cmdUnverify(cmd *Command) {
device := getDevice(cmd)
if device == nil {
@ -243,7 +309,7 @@ func cmdImportKeys(cmd *Command) {
cmd.Reply("Failed to read %s: %v", path, err)
return
}
passphrase, ok := cmd.MainView.AskPassword("Key import", false)
passphrase, ok := cmd.MainView.AskPassword("Key import", "passphrase", "", false)
if !ok {
cmd.Reply("Passphrase entry cancelled")
return
@ -263,7 +329,7 @@ func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) {
cmd.Reply("Failed to get absolute path: %v", err)
return
}
passphrase, ok := cmd.MainView.AskPassword("Key export", true)
passphrase, ok := cmd.MainView.AskPassword("Key export", "passphrase", "", true)
if !ok {
cmd.Reply("Passphrase entry cancelled")
return
@ -299,3 +365,333 @@ func cmdExportRoomKeys(cmd *Command) {
}
exportKeys(cmd, sessions)
}
const ssssHelp = `Usage: /%s <subcommand> [...]
Subcommands:
* status [key ID] - Check the status of your SSSS.
* generate [--set-default] - Generate a SSSS key and optionally set it as the default.
* set-default <key ID> - Set a SSSS key as the default.`
func cmdSSSS(cmd *Command) {
if len(cmd.Args) == 0 {
cmd.Reply(ssssHelp, cmd.OrigCommand)
return
}
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
switch strings.ToLower(cmd.Args[0]) {
case "status":
keyID := ""
if len(cmd.Args) > 1 {
keyID = cmd.Args[1]
}
cmdS4Status(cmd, mach, keyID)
case "generate":
setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default"
cmdS4Generate(cmd, mach, setDefault)
case "set-default":
if len(cmd.Args) < 2 {
cmd.Reply("Usage: /%s set-default <key ID>", cmd.OrigCommand)
return
}
cmdS4SetDefault(cmd, mach, cmd.Args[1])
default:
cmd.Reply(ssssHelp, cmd.OrigCommand)
}
}
func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) {
var keyData *ssss.KeyMetadata
var err error
if len(keyID) == 0 {
keyID, keyData, err = mach.SSSS.GetDefaultKeyData()
} else {
keyData, err = mach.SSSS.GetKeyData(keyID)
}
if errors.Is(err, ssss.ErrNoDefaultKeyAccountDataEvent) {
cmd.Reply("SSSS is not set up: no default key set")
return
} else if err != nil {
cmd.Reply("Failed to get key data: %v", err)
return
}
hasPassphrase := "no"
if keyData.Passphrase != nil {
hasPassphrase = fmt.Sprintf("yes (alg=%s,bits=%d,iter=%d)", keyData.Passphrase.Algorithm, keyData.Passphrase.Bits, keyData.Passphrase.Iterations)
}
algorithm := keyData.Algorithm
if algorithm != ssss.AlgorithmAESHMACSHA2 {
algorithm += " (not supported!)"
}
cmd.Reply("Default key is set.\n Key ID: %s\n Has passphrase: %s\n Algorithm: %s", keyID, hasPassphrase, algorithm)
}
func cmdS4Generate(cmd *Command, mach *crypto.OlmMachine, setDefault bool) {
passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "", true)
if !ok {
return
}
key, err := ssss.NewKey(passphrase)
if err != nil {
cmd.Reply("Failed to generate new key: %v", err)
return
}
err = mach.SSSS.SetKeyData(key.ID, key.Metadata)
if err != nil {
cmd.Reply("Failed to upload key metadata: %v", err)
return
}
// TODO if we start persisting command replies, the recovery key needs to be moved into a popup
cmd.Reply("Successfully generated key %s\nRecovery key: %s", key.ID, key.RecoveryKey())
if setDefault {
err = mach.SSSS.SetDefaultKeyID(key.ID)
if err != nil {
cmd.Reply("Failed to set key as default: %v", err)
}
} else {
cmd.Reply("You can use `/%s set-default %s` to set it as the default", cmd.OrigCommand, key.ID)
}
}
func cmdS4SetDefault(cmd *Command, mach *crypto.OlmMachine, keyID string) {
_, err := mach.SSSS.GetKeyData(keyID)
if err != nil {
if errors.Is(err, mautrix.MNotFound) {
cmd.Reply("Couldn't find key data on server")
} else {
cmd.Reply("Failed to fetch key data: %v", err)
}
return
}
err = mach.SSSS.SetDefaultKeyID(keyID)
if err != nil {
cmd.Reply("Failed to set key as default: %v", err)
} else {
cmd.Reply("Successfully set key %s as default", keyID)
}
}
const crossSigningHelp = `Usage: /%s <subcommand> [...]
Subcommands:
* status
Check the status of your own cross-signing keys.
* generate [--force]
Generate and upload new cross-signing keys.
This will prompt you to enter your account password.
If you already have existing keys, --force is required.
* self-sign
Sign the current device with cached cross-signing keys.
* fetch [--save-to-disk]
Fetch your cross-signing keys from SSSS and decrypt them.
If --save-to-disk is specified, the keys are saved to disk.
* upload
Upload your cross-signing keys to SSSS.`
func cmdCrossSigning(cmd *Command) {
if len(cmd.Args) == 0 {
cmd.Reply(crossSigningHelp, cmd.OrigCommand)
return
}
client := cmd.Matrix.Client()
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
switch strings.ToLower(cmd.Args[0]) {
case "status":
cmdCrossSigningStatus(cmd, mach)
case "generate":
force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force"
cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force)
case "fetch":
saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk"
cmdCrossSigningFetch(cmd, mach, saveToDisk)
case "upload":
cmdCrossSigningUpload(cmd, mach)
case "self-sign":
cmdCrossSigningSelfSign(cmd, mach)
default:
cmd.Reply(crossSigningHelp, cmd.OrigCommand)
}
}
func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) {
keys := mach.GetOwnCrossSigningPublicKeys()
if keys == nil {
if mach.CrossSigningKeys != nil {
cmd.Reply("Cross-signing keys are cached, but not published")
} else {
cmd.Reply("Didn't find published cross-signing keys")
}
return
}
if mach.CrossSigningKeys != nil {
cmd.Reply("Cross-signing keys are published and private keys are cached")
} else {
cmd.Reply("Cross-signing keys are published, but private keys are not cached")
}
cmd.Reply("Master key: %s", keys.MasterKey)
cmd.Reply("User signing key: %s", keys.UserSigningKey)
cmd.Reply("Self-signing key: %s", keys.SelfSigningKey)
}
func cmdCrossSigningFetch(cmd *Command, mach *crypto.OlmMachine, saveToDisk bool) {
key := getSSSS(cmd, mach)
if key == nil {
return
}
err := mach.FetchCrossSigningKeysFromSSSS(key)
if err != nil {
cmd.Reply("Error fetching cross-signing keys: %v", err)
return
}
if saveToDisk {
cmd.Reply("Saving keys to disk is not yet implemented")
}
cmd.Reply("Successfully unlocked cross-signing keys")
}
func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) {
if !force {
existingKeys := mach.GetOwnCrossSigningPublicKeys()
if existingKeys != nil {
cmd.Reply("Found existing cross-signing keys. Use `--force` if you want to overwrite them.")
return
}
}
keys, err := mach.GenerateCrossSigningKeys()
if err != nil {
cmd.Reply("Failed to generate cross-signing keys: %v", err)
return
}
err = mach.PublishCrossSigningKeys(keys, func(uia *mautrix.RespUserInteractive) interface{} {
if !uia.HasSingleStageFlow(mautrix.AuthTypePassword) {
for _, flow := range uia.Flows {
if len(flow.Stages) != 1 {
return nil
}
cmd.Reply("Opening browser for authentication")
err := container.UIAFallback(flow.Stages[0], uia.Session)
if err != nil {
cmd.Reply("Authentication failed: %v", err)
return nil
}
return &mautrix.ReqUIAuthFallback{
Session: uia.Session,
User: mach.Client.UserID.String(),
}
}
cmd.Reply("No supported authentication mechanisms found")
return nil
}
password, ok := cmd.MainView.AskPassword("Account password", "", "correct horse battery staple", false)
if !ok {
return nil
}
return &mautrix.ReqUIAuthLogin{
BaseAuthData: mautrix.BaseAuthData{
Type: mautrix.AuthTypePassword,
Session: uia.Session,
},
User: mach.Client.UserID.String(),
Password: password,
}
})
if err != nil {
cmd.Reply("Failed to publish cross-signing keys: %v", err)
return
}
cmd.Reply("Successfully generated and published cross-signing keys")
err = mach.SignOwnMasterKey()
if err != nil {
cmd.Reply("Failed to sign master key with device key: %v", err)
}
}
func getSSSS(cmd *Command, mach *crypto.OlmMachine) *ssss.Key {
_, keyData, err := mach.SSSS.GetDefaultKeyData()
if err != nil {
if errors.Is(err, mautrix.MNotFound) {
cmd.Reply("SSSS not set up, use `!ssss generate --set-default` first")
} else {
cmd.Reply("Failed to fetch default SSSS key data: %v", err)
}
return nil
}
var key *ssss.Key
if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 {
passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "correct horse battery staple", false)
if !ok {
return nil
}
key, err = keyData.VerifyPassphrase(passphrase)
if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
cmd.Reply("Incorrect passphrase")
return nil
}
} else {
recoveryKey, ok := cmd.MainView.AskPassword("Recovery key", "", "tDAK LMRH PiYE bdzi maCe xLX5 wV6P Nmfd c5mC wLef 15Fs VVSc", false)
if !ok {
return nil
}
key, err = keyData.VerifyRecoveryKey(recoveryKey)
if errors.Is(err, ssss.ErrInvalidRecoveryKey) {
cmd.Reply("Malformed recovery key")
return nil
} else if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
cmd.Reply("Incorrect recovery key")
return nil
}
}
// All the errors should already be handled above, this is just for backup
if err != nil {
cmd.Reply("Failed to get SSSS key: %v", err)
return nil
}
return key
}
func cmdCrossSigningUpload(cmd *Command, mach *crypto.OlmMachine) {
if mach.CrossSigningKeys == nil {
cmd.Reply("Cross-signing keys not cached, use `!%s generate` first", cmd.OrigCommand)
return
}
key := getSSSS(cmd, mach)
if key == nil {
return
}
err := mach.UploadCrossSigningKeysToSSSS(key, mach.CrossSigningKeys)
if err != nil {
cmd.Reply("Failed to upload keys to SSSS: %v", err)
} else {
cmd.Reply("Successfully uploaded cross-signing keys to SSSS")
}
}
func cmdCrossSigningSelfSign(cmd *Command, mach *crypto.OlmMachine) {
if mach.CrossSigningKeys == nil {
cmd.Reply("Cross-signing keys not cached")
return
}
err := mach.SignOwnDevice(mach.OwnIdentity())
if err != nil {
cmd.Reply("Failed to self-sign: %v", err)
} else {
cmd.Reply("Successfully self-signed. This device is now trusted by other devices")
}
}

View File

@ -29,6 +29,7 @@ func cmdNoCrypto(cmd *Command) {
var (
cmdDevices = cmdNoCrypto
cmdDevice = cmdNoCrypto
cmdVerifyDevice = cmdNoCrypto
cmdVerify = cmdNoCrypto
cmdUnverify = cmdNoCrypto
cmdBlacklist = cmdNoCrypto

View File

@ -17,6 +17,9 @@
package ui
import (
"fmt"
"strings"
"maunium.net/go/mauview"
"maunium.net/go/tcell"
)
@ -41,13 +44,20 @@ type PasswordModal struct {
parent *MainView
}
func (view *MainView) AskPassword(title string, isNew bool) (string, bool) {
pwm := NewPasswordModal(view, title, isNew)
func (view *MainView) AskPassword(title, thing, placeholder string, isNew bool) (string, bool) {
pwm := NewPasswordModal(view, title, thing, placeholder, isNew)
view.ShowModal(pwm)
view.parent.Render()
return pwm.Wait()
}
func NewPasswordModal(parent *MainView, title string, isNew bool) *PasswordModal {
func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal {
if placeholder == "" {
placeholder = "correct horse battery staple"
}
if thing == "" {
thing = strings.ToLower(title)
}
pwm := &PasswordModal{
parent: parent,
form: mauview.NewForm(),
@ -64,13 +74,13 @@ func NewPasswordModal(parent *MainView, title string, isNew bool) *PasswordModal
pwm.text = mauview.NewTextField()
if isNew {
pwm.text.SetText("Create a passphrase")
pwm.text.SetText(fmt.Sprintf("Create a %s", thing))
} else {
pwm.text.SetText("Enter the passphrase")
pwm.text.SetText(fmt.Sprintf("Enter the %s", thing))
}
pwm.input = mauview.NewInputField().
SetMaskCharacter('*').
SetPlaceholder("correct horse battery staple")
SetPlaceholder(placeholder)
pwm.form.AddComponent(pwm.text, 1, 1, 3, 1)
pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1)
@ -78,10 +88,10 @@ func NewPasswordModal(parent *MainView, title string, isNew bool) *PasswordModal
height += 3
pwm.confirmInput = mauview.NewInputField().
SetMaskCharacter('*').
SetPlaceholder("correct horse battery staple").
SetPlaceholder(placeholder).
SetChangedFunc(pwm.HandleChange)
pwm.input.SetChangedFunc(pwm.HandleChange)
pwm.confirmText = mauview.NewTextField().SetText("Confirm passphrase")
pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing))
pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1)
pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1)
@ -125,9 +135,9 @@ func (pwm *PasswordModal) ClickSubmit() {
func (pwm *PasswordModal) Wait() (string, bool) {
select {
case result := <- pwm.outputChan:
case result := <-pwm.outputChan:
return result, true
case <- pwm.cancelChan:
case <-pwm.cancelChan:
return "", false
}
}

View File

@ -771,7 +771,7 @@ func (view *RoomView) getRelationForNewEvent() *ifc.Relation {
}
} else if view.replying != nil {
return &ifc.Relation{
Type: event.RelReference,
Type: event.RelReply,
Event: view.replying,
}
}

View File

@ -148,7 +148,8 @@ func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod {
return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}}
}
func (vm *VerificationModal) VerifySASMatch(_ *crypto.DeviceIdentity, data crypto.SASData) bool {
func (vm *VerificationModal) VerifySASMatch(device *crypto.DeviceIdentity, data crypto.SASData) bool {
vm.device = device
var typeName string
if data.Type() == event.SASDecimal {
typeName = "numbers"