From e347cc4e56a9197ac1032607c184377a11e10e8d Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Wed, 8 Jan 2025 13:08:52 +1000 Subject: [PATCH] Add creds migration and introduce -logout --- README.md | 21 +++++--- main.go | 36 ++++++++++++-- migrate.go | 60 +++++++++++++++++++++++ migrate_test.go | 91 +++++++++++++++++++++++++++++++++++ testdata/invalidsession.dat | 1 + testdata/testsessionv100.dat | 1 + testdata/testsessionv120.dat | Bin 0 -> 4133 bytes 7 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 migrate.go create mode 100644 migrate_test.go create mode 100644 testdata/invalidsession.dat create mode 100644 testdata/testsessionv100.dat create mode 100644 testdata/testsessionv120.dat 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 0000000000000000000000000000000000000000..ac2ecc8ee87d51caa82ea766bdf2f69ae67b2326 GIT binary patch literal 4133 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