| 1 | package client |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "lchat/internal/e2ee" |
| 9 | ) |
| 10 | |
| 11 | // The cross-implementation vector: a JS-sealed envelope (fixed DEK) from |
| 12 | // home/test/gen-home-vector.mjs — the same generator the Swift HomeCrypto |
| 13 | // tests pin against. Opening it byte-for-byte proves the AES-GCM layout, |
| 14 | // padding, and EHB1 framing match the browser's. |
| 15 | func TestOpenHomeBlobJSVector(t *testing.T) { |
| 16 | raw, err := os.ReadFile("testdata/home-vector.txt") |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | envelope, err := e2ee.B64uDecode(strings.TrimSpace(string(raw))) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | dek, err := e2ee.B64uDecode("AAcOFRwjKjE4P0ZNVFtiaXB3foWMk5qhqK-2vcTL0tk") // gen-home-vector.mjs's fixed DEK |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | v, err := OpenHomeBlob(envelope, dek) |
| 29 | if err != nil { |
| 30 | t.Fatalf("OpenHomeBlob: %v", err) |
| 31 | } |
| 32 | if v.V != 1 || len(v.Servers) != 1 || v.Servers[0].URL != "https://test.example.com" || v.Servers[0].Name != "Test" { |
| 33 | t.Fatalf("vector contents: %+v", v) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestSealOpenHomeBlobRoundTrip(t *testing.T) { |
| 38 | dek, err := e2ee.GenerateKey() |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | in := &Vault{V: 1, Servers: []VaultServer{ |
| 43 | {URL: "https://a.11ch.at", Name: "Alpha", Starred: true, |
| 44 | Credentials: &VaultCredentials{Token: "tokA", UserPub: "pubA", UserPriv: "privA", Name: "Paul"}}, |
| 45 | {URL: "https://b.11ch.at", Name: "Bare save"}, // credential-less entry |
| 46 | {URL: "https://c.11ch.at", Archived: true, |
| 47 | Credentials: &VaultCredentials{Token: "tokC", UserPub: "pubC", UserPriv: "privC"}}, |
| 48 | }} |
| 49 | envelope, err := SealHomeBlob(in, dek, map[string]string{"cred-1": "wrapped-opaque"}) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | out, err := OpenHomeBlob(envelope, dek) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if len(out.Servers) != 3 || out.Servers[0].Credentials.Token != "tokA" || |
| 58 | !out.Servers[0].Starred || !out.Servers[2].Archived || out.Servers[1].Credentials != nil { |
| 59 | t.Fatalf("round trip: %+v", out) |
| 60 | } |
| 61 | |
| 62 | // Session projection: credentials become a live space session; the vault |
| 63 | // name backfills when credentials carry none; bare entries yield nil. |
| 64 | s := out.Servers[0].Session() |
| 65 | if s == nil || s.Base != "https://a.11ch.at" || s.Token != "tokA" || s.Name != "Paul" || !s.LabelPublished { |
| 66 | t.Fatalf("session projection: %+v", s) |
| 67 | } |
| 68 | if got := out.Servers[2].Session(); got == nil || got.Name != "" { |
| 69 | t.Fatalf("nameless credentials: %+v", got) |
| 70 | } |
| 71 | if out.Servers[1].Session() != nil { |
| 72 | t.Fatal("bare entry must project no session") |
| 73 | } |
| 74 | |
| 75 | // Wrong key must not open. |
| 76 | other, _ := e2ee.GenerateKey() |
| 77 | if _, err := OpenHomeBlob(envelope, other); err == nil { |
| 78 | t.Fatal("opened with the wrong DEK") |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // OpenHandoff is the CLI's end of restore.js's cli branch: the page seals the |
| 83 | // home session to the ephemeral pubkey; only the matching private key + |
| 84 | // matching nonce open it. |
| 85 | func TestOpenHandoff(t *testing.T) { |
| 86 | kp, err := e2ee.GenerateUserKeypair() |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | sealed, err := e2ee.SealForPub(kp.PublicKey, HomeSession{V: 1, Token: "hometok", CredID: "cred-1", DEK: "dEk"}) |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | back := e2ee.B64uEncode([]byte(`{"nonce":"n-123","wrapped":"` + sealed + `"}`)) |
| 95 | |
| 96 | hs, err := OpenHandoff(back, "n-123", kp.PrivateKey) |
| 97 | if err != nil { |
| 98 | t.Fatalf("OpenHandoff: %v", err) |
| 99 | } |
| 100 | if hs.Token != "hometok" || hs.CredID != "cred-1" || hs.DEK != "dEk" { |
| 101 | t.Fatalf("session: %+v", hs) |
| 102 | } |
| 103 | |
| 104 | if _, err := OpenHandoff(back, "other-nonce", kp.PrivateKey); err == nil { |
| 105 | t.Fatal("nonce mismatch accepted") |
| 106 | } |
| 107 | otherKP, _ := e2ee.GenerateUserKeypair() |
| 108 | if _, err := OpenHandoff(back, "n-123", otherKP.PrivateKey); err == nil { |
| 109 | t.Fatal("wrong ephemeral key accepted") |
| 110 | } |
| 111 | if _, err := OpenHandoff("not base64 json", "n", kp.PrivateKey); err == nil { |
| 112 | t.Fatal("garbage accepted") |
| 113 | } |
| 114 | } |
| 115 | |