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:
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/<id>.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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user