diff --git a/README.md b/README.md index dba5722..064b4f1 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ You will need: - Telegram API ID - Telegram API HASH -The program provides easy to follow instructions on how to get -those. +The program provides easy to follow instructions on how to get those. To authenticate, you will use your Telegram Account phone number and the code, that will be sent to you in-app or a text message (SMS). @@ -40,17 +39,25 @@ You can also run the deletion from script. Follow these steps: wipemychat -wipe 12345,56789 ``` -## Resetting login details +### Logging out -If you accidentally entered the wrong login details, or App Hash and App -Secret, you can reset them by running: +If you need to log in under a different account (or phone number), you can +logout without deleting the application credentials by running: +``` +wipemychat -logout +``` + +### Complete reset + +If you need to completely reset the authentication, for example, if you +accidentally entered the wrong login details, or App Hash and App Secret, run: ``` wipemychat -reset ``` -This deletes both files with secrets, and you will be asked to authenticate -again. +This deletes both files: session and application credentials. You will be asked +to authenticate again. ## Licence GNU Public Licence 3.0, see [LICENCE][2] diff --git a/main.go b/main.go index 1db9f4b..1d12186 100644 --- a/main.go +++ b/main.go @@ -51,9 +51,12 @@ type Params struct { ApiHash string Phone string + // Reset requests removal of the session and API credentials files. Reset bool - List bool + // Logout requests removal of the session file. + Logout bool + List bool Batch chatIDs Version bool @@ -110,13 +113,20 @@ func (c *chatIDs) String() string { func parseCmdLine() (Params, error) { p := Params{CacheDirName: cacheDirName} { + // auth options flag.IntVar(&p.ApiID, "api-id", osenv.Secret("APP_ID", 0), "Telegram API ID") flag.StringVar(&p.ApiHash, "api-token", osenv.Secret("APP_HASH", ""), "Telegram API token") flag.StringVar(&p.Phone, "phone", osenv.Value("PHONE", ""), "phone `number` in international format for authentication (optional)") - flag.BoolVar(&p.Reset, "reset", false, "reset authentication") + + // reset options + flag.BoolVar(&p.Reset, "reset", false, "reset authentication (logout and remove credentials)") + flag.BoolVar(&p.Logout, "logout", false, "logout current account, use this to login as another user with the same API ID") + + // batch mode flag.BoolVar(&p.List, "list", false, "list channels and their IDs") flag.Var(&p.Batch, "wipe", "batch mode, specify comma separated chat IDs on the command line") + // sundry flag.BoolVar(&p.Version, "v", false, "print version and exit") flag.BoolVar(&p.Verbose, "verbose", osenv.Value("DEBUG", "") != "", "verbose output") flag.StringVar(&p.Trace, "trace", osenv.Value("TRACE_FILE", ""), "trace `filename`") @@ -157,15 +167,33 @@ func run(ctx context.Context, p Params) error { header(os.Stdout) + sessfile := filepath.Join(p.cacheDir, "session.dat") + if migrated, err := migratev120(sessfile); err != nil { + return err + } else if migrated { + fmt.Fprintln(os.Stdout, "session file was migrated to new format") + } + sessStorage := session.FileStorage{Path: filepath.Join(p.cacheDir, "session.dat")} apiCredsFile := filepath.Join(p.cacheDir, "telegram.dat") + if p.Logout { + if err := unlink(sessStorage.Path); err != nil { + return err + } else { + fmt.Fprintln(os.Stdout, "you were logged out") + } + os.Exit(0) + } if p.Reset { for _, file := range []string{sessStorage.Path, apiCredsFile} { if err := unlink(file); err != nil { - return err + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("error deleting %s: %w", file, err) } } - fmt.Fprintln(os.Stdout, "credentials were removed") + fmt.Fprintln(os.Stdout, "logged out and credentials removed") os.Exit(0) } diff --git a/migrate.go b/migrate.go new file mode 100644 index 0000000..a649f08 --- /dev/null +++ b/migrate.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + + tds "github.com/gotd/td/session" + + "github.com/rusq/wipemychat/internal/session" +) + +const v1signature = `{"Version":1` + +// migratev120 migrates session file from v1 to v1.2.0+ (enables encryption). +// sessfile is the path to the session file. It returns true if the file was +// migrated, false if it was already migrated or invalid. +func migratev120(sessfile string) (bool, error) { + f, err := os.Open(sessfile) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + defer f.Close() + if f, err := f.Stat(); err != nil { + return false, err + } else if f.Size() == 0 { + return false, nil + } + b := make([]byte, len(v1signature)) + if n, err := f.Read(b); err != nil { + return false, fmt.Errorf("failed to read session file: %w", err) + } else if n != len(v1signature) { + return false, fmt.Errorf("invalid session file") + } + + if !bytes.Equal(b[:], []byte(v1signature)) { + // already migrated or invalid + return false, nil + } + // needs to be migrated + if err := f.Close(); err != nil { + return false, fmt.Errorf("close error: %w", err) + } + v1loader := tds.FileStorage{Path: sessfile} + sess, err := v1loader.LoadSession(context.Background()) + if err != nil { + return false, fmt.Errorf("failed to load session: %w", err) + } + + // overwrite with new version + v120loader := session.FileStorage{Path: sessfile} + if err := v120loader.StoreSession(context.Background(), sess); err != nil { + return false, fmt.Errorf("failed to save session: %w", err) + } + return true, nil +} diff --git a/migrate_test.go b/migrate_test.go new file mode 100644 index 0000000..77fa32c --- /dev/null +++ b/migrate_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/rusq/wipemychat/internal/session" +) + +func copyfile(t *testing.T, src, dst string) error { + t.Helper() + sf, err := os.Open(src) + if err != nil { + t.Fatalf("source: %s", err) + } + defer sf.Close() + df, err := os.Create(dst) + if err != nil { + t.Fatalf("destination: %s", err) + } + defer df.Close() + if _, err := io.Copy(df, sf); err != nil { + t.Fatalf("copy: %s", err) + } + return nil +} + +func Test_migratev120(t *testing.T) { + type args struct { + sessfile string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "migrates v1 file", + args: args{sessfile: "testdata/testsessionv100.dat"}, + want: true, + }, + { + name: "doesn't touch v1.20 file", + args: args{sessfile: "testdata/testsessionv120.dat"}, + want: false, + }, + { + name: "invalid file", + args: args{sessfile: "testdata/invalidsession.dat"}, + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // substitute the source file with it's copy in a temporary directory + tmpdir := t.TempDir() + srcfilename := filepath.Base(tt.args.sessfile) + dstfile := filepath.Join(tmpdir, srcfilename) + copyfile(t, tt.args.sessfile, filepath.Join(tmpdir, srcfilename)) + migrated, err := migratev120(dstfile) + if (err != nil) != tt.wantErr { + t.Errorf("migratev120() error = %v, wantErr %v", err, tt.wantErr) + return + } + if migrated != tt.want { + t.Errorf("migratev120() = %v, want %v", migrated, tt.want) + } + if migrated { + // verify the file is valid v1.20 + sess := session.FileStorage{Path: dstfile} + data, err := sess.LoadSession(context.Background()) + if err != nil { + t.Errorf("migratev120() = %v, want %v", err, nil) + } + if data == nil { + t.Errorf("migratev120() = %v, want %v", data, "not nil") + } + sigSz := len(v1signature) + if !bytes.Equal(data[:sigSz], []byte(v1signature)) { + t.Errorf("migratev120() = %v, want %v", data[:sigSz], v1signature) + } + } + }) + } +} diff --git a/testdata/invalidsession.dat b/testdata/invalidsession.dat new file mode 100644 index 0000000..d9b0c02 --- /dev/null +++ b/testdata/invalidsession.dat @@ -0,0 +1 @@ +{"Ver"} \ No newline at end of file diff --git a/testdata/testsessionv100.dat b/testdata/testsessionv100.dat new file mode 100644 index 0000000..9d674ec --- /dev/null +++ b/testdata/testsessionv100.dat @@ -0,0 +1 @@ +{"Version":1,"Data":{"Config":{"BlockedMode":false,"ForceTryIpv6":false,"Date":1536300799,"Expires":1536304334,"TestMode":false,"ThisDC":2,"DCOptions":[{"Flags":0,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":1,"IPAddress":"1.1.1.5","Port":443,"Secret":null},{"Flags":16,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":true,"ThisPortOnly":false,"ID":1,"IPAddress":"1.1.1.5","Port":443,"Secret":null},{"Flags":1,"Ipv6":true,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":1,"IPAddress":"2001:0abc:defa:f000:0000:0000:0000:000a","Port":443,"Secret":null},{"Flags":0,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":2,"IPAddress":"1.1.1.4","Port":443,"Secret":null},{"Flags":16,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":true,"ThisPortOnly":false,"ID":2,"IPAddress":"1.1.1.4","Port":443,"Secret":null},{"Flags":2,"Ipv6":false,"MediaOnly":true,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":2,"IPAddress":"1.1.1.2","Port":443,"Secret":null},{"Flags":1,"Ipv6":true,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":2,"IPAddress":"2001:0abc:defa:f000:0000:0000:0000:000a","Port":443,"Secret":null},{"Flags":3,"Ipv6":true,"MediaOnly":true,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":2,"IPAddress":"2001:0abc:def0:f002:0000:0000:0000:000b","Port":443,"Secret":null},{"Flags":0,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":3,"IPAddress":"1.1.1.1","Port":443,"Secret":null},{"Flags":16,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":true,"ThisPortOnly":false,"ID":3,"IPAddress":"1.1.1.1","Port":443,"Secret":null},{"Flags":1,"Ipv6":true,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":3,"IPAddress":"2001:0abc:defd:f003:0000:0000:0000:000a","Port":443,"Secret":null},{"Flags":0,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":4,"IPAddress":"1.4.67.9","Port":443,"Secret":null},{"Flags":16,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":true,"ThisPortOnly":false,"ID":4,"IPAddress":"1.14.17.9","Port":443,"Secret":null},{"Flags":1,"Ipv6":true,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":4,"IPAddress":"2001:0123:4567:f004:0000:0000:0000:000a","Port":443,"Secret":null},{"Flags":2,"Ipv6":false,"MediaOnly":true,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":4,"IPAddress":"1.1.1.9","Port":443,"Secret":null},{"Flags":3,"Ipv6":true,"MediaOnly":true,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":4,"IPAddress":"2001:0012:3456:f004:0000:0000:0000:000b","Port":443,"Secret":null},{"Flags":1,"Ipv6":true,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":5,"IPAddress":"2001:0123:4567:f005:0000:0000:0000:000a","Port":443,"Secret":null},{"Flags":0,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":false,"ThisPortOnly":false,"ID":5,"IPAddress":"1.1.5.19","Port":443,"Secret":null},{"Flags":16,"Ipv6":false,"MediaOnly":false,"TCPObfuscatedOnly":false,"CDN":false,"Static":true,"ThisPortOnly":false,"ID":5,"IPAddress":"1.18.5.17","Port":443,"Secret":null}],"DCTxtDomainName":"abcd.efgh.com","TmpSessions":0,"WebfileDCID":4},"DC":2,"Addr":"","AuthKey":"UypugUhINJghExngTJ5L2fZ/ckBKey75DgpwWSiBROHVZad1UhGFyvmb7aQxO1g6W+8ERG44srIddnkQm8tl+vMtQO1jPtSv+scBCe3CsFpZFPa6Di3QtJ2AcuyY+w6nOMsEKAQK+kwP1dvta3wLcNHViivE/BBJzeUpwYVKG3/4nQuoJ1HnzK8FUq7E/adYx4w4DZGSlx7k33mC+Uazwz5liSn+vniM1qsz/xVwVUfSwe0FHK6+XmfoqIQ/C0Gvtc7TkRrdbYT0NsUIItw2Lza+zAhkwnKRZUIpHbF4hFJ6RnROKQJYew7DxCKos/RedUmYTTou5XabF7OcUZWAoQ==","AuthKeyID":"xNUXXKHzJvM=","Salt":123948397235972349}} diff --git a/testdata/testsessionv120.dat b/testdata/testsessionv120.dat new file mode 100644 index 0000000..ac2ecc8 Binary files /dev/null and b/testdata/testsessionv120.dat differ