Add identity management

Manage several TeamSpeak identities like the official client instead of
using one auto-generated identity file.

- IdentityStore/IdentityEntry keep each identity as a TeamSpeak-format INI
  in ~/.ts3jclient/identities, so files interchange with the TS3 client;
  supports create, import, export, rename, remove and a cancellable
  security-level search. A legacy identity.ini is migrated in on first load.
- Settings hold the default identity; bookmarks may pin their own, falling
  back to the default when unset.
- TeamspeakConnection now connects with the identity it is handed.
- Swing: Tools -> Identities manager, plus an identity drop-down in the
  connect and bookmark forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 07:42:08 +00:00
parent f912cab07b
commit 17ad6cde1c
12 changed files with 880 additions and 38 deletions

View File

@@ -7,6 +7,8 @@ public final class Bookmark {
public int port = 9987;
public String nickname;
public String password = "";
/** Identity to connect with; empty means "use the default identity". */
public String identityId = "";
public Bookmark() {
}

View File

@@ -48,6 +48,7 @@ public final class Bookmarks {
bm.port = parseInt(p.getProperty(prefix + "port"), 9987);
bm.nickname = p.getProperty(prefix + "nickname", "");
bm.password = p.getProperty(prefix + "password", "");
bm.identityId = p.getProperty(prefix + "identityId", "");
if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
}
return b;
@@ -64,6 +65,7 @@ public final class Bookmarks {
p.setProperty(prefix + "port", Integer.toString(bm.port));
p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname));
p.setProperty(prefix + "password", nullToEmpty(bm.password));
p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId));
}
try {
if (!DIR.isDirectory()) {

View File

@@ -0,0 +1,60 @@
package com.ts3client.config;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import java.io.File;
/**
* One managed identity: a TS3 key pair plus the user-visible name it is filed
* under. Backed by a TeamSpeak-compatible identity INI file on disk.
*/
public final class IdentityEntry {
private final String id;
private final File file;
private final LocalIdentity identity;
private String name;
IdentityEntry(String id, String name, File file, LocalIdentity identity) {
this.id = id;
this.name = name;
this.file = file;
this.identity = identity;
}
/** Stable key used to reference this identity from settings and bookmarks. */
public String getId() {
return id;
}
public String getName() {
return name;
}
void setName(String name) {
this.name = name;
}
public File getFile() {
return file;
}
public LocalIdentity getIdentity() {
return identity;
}
/** The TeamSpeak unique ID (base64 SHA-1 of the public key). */
public String getUniqueId() {
return identity.getUid().toBase64();
}
/** Current security level ("hash cash" leading-zero bits) of the identity. */
public int getSecurityLevel() {
return identity.getSecurityLevel();
}
@Override
public String toString() {
return name;
}
}

View File

@@ -0,0 +1,312 @@
package com.ts3client.config;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import org.ini4j.Ini;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Manages the user's TeamSpeak identities, mirroring the official client:
* several identities may coexist, one is the default, and any of them can be
* imported from or exported to a TS3 identity INI file.
*
* <p>Each identity lives in its own {@code ~/.ts3jclient/identities/&lt;id&gt;.ini}
* file in TeamSpeak's own format, so files can be exchanged with the official
* client verbatim. The display name is kept in the INI's {@code id} key, again
* as TeamSpeak does.
*/
public final class IdentityStore {
/** Security level new identities are generated with, matching the TS3 client's default. */
public static final int DEFAULT_SECURITY_LEVEL = 8;
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File IDENTITY_DIR = new File(DIR, "identities");
private final List<IdentityEntry> entries = new ArrayList<>();
/**
* Reports progress while improving an identity's security level.
* Returning {@code false} aborts the search, keeping the best result so far.
*/
public interface ImproveProgress {
boolean onProgress(int level, long offset);
}
private IdentityStore() {
}
/** Loads all identities from disk, migrating a legacy single-identity file if needed. */
public static IdentityStore load(Settings settings) {
IdentityStore store = new IdentityStore();
File[] files = IDENTITY_DIR.listFiles((d, n) -> n.toLowerCase(Locale.ROOT).endsWith(".ini"));
if (files != null) {
for (File f : files) {
IdentityEntry e = read(f);
if (e != null) store.entries.add(e);
}
}
if (store.entries.isEmpty()) store.migrateLegacy(settings);
store.sort();
return store;
}
/** Adopts the pre-multi-identity {@code identity.ini} as the first managed identity. */
private void migrateLegacy(Settings settings) {
File legacy = settings.identityFile();
if (legacy == null || !legacy.isFile()) return;
try {
LocalIdentity identity = LocalIdentity.read(legacy);
create("Default", identity);
} catch (Exception ignored) {
// Unreadable legacy identity: start fresh instead.
}
}
public List<IdentityEntry> all() {
return entries;
}
public boolean isEmpty() {
return entries.isEmpty();
}
public IdentityEntry byId(String id) {
if (id == null || id.isBlank()) return null;
for (IdentityEntry e : entries) {
if (e.getId().equals(id)) return e;
}
return null;
}
/**
* Resolves the identity to connect with: the requested one, else the configured
* default, else the first available — generating one when the store is empty.
*/
public IdentityEntry resolve(Settings settings, String preferredId) throws Exception {
IdentityEntry e = byId(preferredId);
if (e != null) return e;
e = byId(settings.defaultIdentityId);
if (e == null) {
e = entries.isEmpty() ? generate("Default", DEFAULT_SECURITY_LEVEL) : entries.get(0);
settings.defaultIdentityId = e.getId();
settings.save();
}
return e;
}
/** Generates a brand-new identity. Blocking: key improvement takes time. */
public IdentityEntry generate(String name, int securityLevel) throws Exception {
return create(name, LocalIdentity.generateNew(securityLevel));
}
/** Imports a TeamSpeak identity INI file, keeping its own name unless one is given. */
public IdentityEntry importFile(File source, String name) throws IOException {
LocalIdentity identity = LocalIdentity.read(source);
String label = (name == null || name.isBlank()) ? readName(source, null) : name;
if (label == null || label.isBlank()) label = stripExtension(source.getName());
return create(label, identity);
}
/** Imports an identity from a raw TeamSpeak identity INI stream. */
public IdentityEntry importStream(InputStream in, String name) throws IOException {
LocalIdentity identity = LocalIdentity.read(in);
return create((name == null || name.isBlank()) ? "Imported identity" : name, identity);
}
/** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
public void exportTo(IdentityEntry entry, File target) throws IOException {
write(entry.getIdentity(), entry.getName(), target);
}
public void rename(IdentityEntry entry, String name) throws IOException {
if (name == null || name.isBlank()) return;
entry.setName(name.trim());
write(entry.getIdentity(), entry.getName(), entry.getFile());
sort();
}
/** Deletes an identity, clearing any settings or bookmarks that referenced it. */
public void remove(IdentityEntry entry, Settings settings, Bookmarks bookmarks) {
entries.remove(entry);
//noinspection ResultOfMethodCallIgnored
entry.getFile().delete();
if (entry.getId().equals(settings.defaultIdentityId)) {
settings.defaultIdentityId = entries.isEmpty() ? "" : entries.get(0).getId();
settings.save();
}
if (bookmarks != null) {
boolean changed = false;
for (Bookmark b : bookmarks.all()) {
if (entry.getId().equals(b.identityId)) {
b.identityId = "";
changed = true;
}
}
if (changed) bookmarks.save();
}
}
/**
* Raises an identity's security level towards {@code target} by searching for a
* better key offset, persisting the result. Returns the level reached.
*
* @param progress called periodically; return {@code false} from it to stop early
*/
public int improveSecurity(IdentityEntry entry, int target, ImproveProgress progress) throws IOException {
LocalIdentity identity = entry.getIdentity();
byte[] publicKey = identity.getPublicKeyString().getBytes(StandardCharsets.US_ASCII);
MessageDigest sha1 = sha1();
int best = identity.getSecurityLevel();
long offset = Math.max(identity.getKeyOffset(), identity.getLastCheckedKeyOffset());
try {
while (best < target) {
int level = securityLevel(sha1, publicKey, offset);
if (level > best) {
best = level;
identity.setKeyOffset(offset);
}
offset++;
if ((offset & 0xFFFF) == 0 && progress != null && !progress.onProgress(best, offset)) break;
}
} finally {
identity.setLastCheckedKeyOffset(offset);
write(identity, entry.getName(), entry.getFile());
}
return best;
}
// ---- persistence ----
private IdentityEntry create(String name, LocalIdentity identity) throws IOException {
String id = uniqueId(name);
File file = new File(IDENTITY_DIR, id + ".ini");
write(identity, name, file);
IdentityEntry entry = new IdentityEntry(id, name, file, identity);
entries.add(entry);
sort();
return entry;
}
private static void write(LocalIdentity identity, String name, File file) throws IOException {
File dir = file.getParentFile();
if (dir != null && !dir.isDirectory() && !dir.mkdirs()) {
throw new IOException("Cannot create " + dir);
}
Map<String, String> props = new HashMap<>();
props.put("id", name == null ? "" : name);
identity.save(file, props);
}
private static IdentityEntry read(File file) {
try {
LocalIdentity identity = LocalIdentity.read(file);
String id = stripExtension(file.getName());
String name = readName(file, id);
return new IdentityEntry(id, name, file, identity);
} catch (Exception e) {
return null;
}
}
/** Reads the display name from an identity INI's {@code id} key. */
private static String readName(File file, String fallback) {
try {
Ini ini = new Ini(file);
String name = unquote(ini.get("Identity", "id"));
if (name != null && !name.isBlank()) return name;
} catch (Exception ignored) {
}
return fallback;
}
/** TeamSpeak quotes INI values; ini4j hands them back verbatim. */
private static String unquote(String value) {
if (value == null) return null;
String s = value.trim();
if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) {
s = s.substring(1, s.length() - 1);
}
return s.replace("\\\"", "\"");
}
/** Derives a filesystem-safe, collision-free key from the display name. */
private String uniqueId(String name) {
String base = (name == null ? "" : name).trim().toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-+|-+$)", "");
if (base.isEmpty()) base = "identity";
String candidate = base;
for (int i = 2; taken(candidate); i++) {
candidate = base + "-" + i;
}
return candidate;
}
private boolean taken(String id) {
if (byId(id) != null) return true;
return new File(IDENTITY_DIR, id + ".ini").exists();
}
private void sort() {
entries.sort(Comparator.comparing(e -> e.getName().toLowerCase(Locale.ROOT)));
}
private static String stripExtension(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(0, dot) : fileName;
}
// ---- security level search ----
private static MessageDigest sha1() {
try {
return MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
/** Leading zero bits of SHA-1(publicKey + decimal offset) — TeamSpeak's proof of work. */
private static int securityLevel(MessageDigest sha1, byte[] publicKey, long offset) {
sha1.reset();
sha1.update(publicKey);
sha1.update(Long.toString(offset).getBytes(StandardCharsets.US_ASCII));
return leadingZeroBits(sha1.digest());
}
private static int leadingZeroBits(byte[] data) {
int count = 0;
for (byte b : data) {
if (b == 0) {
count += 8;
continue;
}
for (int bit = 0; bit < 8; bit++) {
if ((b & (1 << bit)) == 0) count++;
else break;
}
break;
}
return count;
}
/** Directory the managed identity files live in. */
public static File directory() {
return IDENTITY_DIR;
}
}

View File

@@ -44,7 +44,10 @@ public final class Settings {
public String lastAddress = "localhost";
public String nickname = System.getProperty("user.name", "TS3J User");
public String serverPassword = "";
/** Legacy single-identity file; only used to migrate into the identity store. */
public String identityFile = new File(DIR, "identity.ini").getAbsolutePath();
/** Id of the identity used when a server doesn't select one of its own. */
public String defaultIdentityId = "";
// ---- audio devices (mixer names; empty = system default) ----
public String inputDevice = "";
@@ -129,6 +132,7 @@ public final class Settings {
nickname = props.getProperty("nickname", nickname);
serverPassword = props.getProperty("serverPassword", serverPassword);
identityFile = props.getProperty("identityFile", identityFile);
defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId);
inputDevice = props.getProperty("inputDevice", inputDevice);
outputDevice = props.getProperty("outputDevice", outputDevice);
inputMode = parseMode(props.getProperty("inputMode"), inputMode);
@@ -156,6 +160,7 @@ public final class Settings {
props.setProperty("nickname", nickname);
props.setProperty("serverPassword", serverPassword);
props.setProperty("identityFile", identityFile);
props.setProperty("defaultIdentityId", defaultIdentityId);
props.setProperty("inputDevice", inputDevice);
props.setProperty("outputDevice", outputDevice);
props.setProperty("inputMode", inputMode.name());

View File

@@ -77,37 +77,25 @@ public final class TeamspeakConnection implements TS3Listener {
return playback;
}
// ---- identity ----
/** Loads the persisted identity, or generates and saves a new one. */
public LocalIdentity loadOrCreateIdentity() throws Exception {
File file = settings.identityFile();
if (file.isFile()) {
try {
return LocalIdentity.read(file);
} catch (Exception e) {
// fall through and regenerate
}
}
LocalIdentity id = LocalIdentity.generateNew(10);
if (!file.getParentFile().isDirectory()) {
//noinspection ResultOfMethodCallIgnored
file.getParentFile().mkdirs();
}
id.save(file);
return id;
/** The identity this connection authenticated with. */
public LocalIdentity getIdentity() {
return identity;
}
// ---- connection lifecycle ----
public void connect(String address, int port, String nickname, String password) {
new Thread(() -> doConnect(address, port, nickname, password), "ts3j-connect").start();
/**
* Connects in the background.
*
* @param identity identity to authenticate with; see {@link com.ts3client.config.IdentityStore}
*/
public void connect(String address, int port, String nickname, String password, LocalIdentity identity) {
new Thread(() -> doConnect(address, port, nickname, password, identity), "ts3j-connect").start();
}
private void doConnect(String address, int port, String nickname, String password) {
private void doConnect(String address, int port, String nickname, String password, LocalIdentity withIdentity) {
try {
ui.onStatus("Loading identity…");
identity = loadOrCreateIdentity();
identity = withIdentity;
model.clear();
playback = audio.createOutput(settings);