Initial import

This commit is contained in:
2026-03-09 22:05:39 +01:00
parent 73631969c9
commit 344c89a8d0
15 changed files with 3894 additions and 10 deletions

331
src/identity.ts Normal file
View File

@@ -0,0 +1,331 @@
import { ecb } from '@noble/ciphers/aes.js';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from "@noble/hashes/sha2.js";
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
import {
BaseGroup,
BaseGroupSecret,
BaseIdentity,
BaseKeyManager,
BaseLocalIdentity,
Contact,
DecryptedAnonReq,
DecryptedGroupData,
DecryptedGroupText,
DecryptedRequest,
DecryptedResponse,
DecryptedText,
EncryptedPayload,
NodeHash,
Secret
} from "./types";
import { BufferReader, bytesToHex, equalBytes, hexToBytes } from "./parser";
// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public".
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
export class Identity extends BaseIdentity {
constructor(publicKey: Uint8Array | string) {
if (typeof publicKey === "string") {
publicKey = hexToBytes(publicKey);
}
super(publicKey);
}
public hash(): NodeHash {
return bytesToHex(this.publicKey.slice(0, 1));
}
public verify(message: Uint8Array, signature: Uint8Array): boolean {
return ed25519.verify(message, signature, this.publicKey);
}
}
export class LocalIdentity extends Identity implements BaseLocalIdentity {
public privateKey: Uint8Array;
constructor(seed: Uint8Array | string) {
if (typeof seed === "string") {
seed = hexToBytes(seed);
}
const { secretKey, publicKey } = ed25519.keygen(seed);
super(publicKey);
this.privateKey = secretKey;
}
public sign(message: Uint8Array): Uint8Array {
return ed25519.sign(message, this.privateKey);
}
public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array {
if (other instanceof Uint8Array) {
return x25519.getSharedSecret(this.privateKey, other);
}
return x25519.getSharedSecret(this.privateKey, other.publicKey);
}
public hash(): NodeHash {
return super.hash();
}
public verify(message: Uint8Array, signature: Uint8Array): boolean {
return super.verify(message, signature);
}
}
export class Group extends BaseGroup {}
export class GroupSecret extends BaseGroupSecret {
public static fromName(name: string): GroupSecret {
if (name === "Public") {
return new GroupSecret(publicSecret);
} else if (!/^#/.test(name)) {
throw new Error("Only the 'Public' group or groups starting with '#' are supported");
}
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
return new GroupSecret(hash.slice(0, 16));
}
public hash(): NodeHash {
return bytesToHex(this.secret.slice(0, 1));
}
public decryptText(encrypted: EncryptedPayload): DecryptedGroupText {
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
if (data.length < 8) {
throw new Error("Invalid ciphertext");
}
const reader = new BufferReader(data);
const timestamp = reader.readTimestamp();
const flags = reader.readByte();
const textType = (flags >> 2) & 0x3F;
const attempt = flags & 0x03;
const message = new TextDecoder('utf-8').decode(reader.readBytes());
return {
timestamp,
textType,
attempt,
message
}
}
public decryptData(encrypted: EncryptedPayload): DecryptedGroupData {
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
if (data.length < 8) {
throw new Error("Invalid ciphertext");
}
const reader = new BufferReader(data);
return {
timestamp: reader.readTimestamp(),
data: reader.readBytes(reader.remainingBytes())
};
}
private macThenDecrypt(cipherMAC: Uint8Array, cipherText: Uint8Array): Uint8Array {
const mac = hmac(sha256, this.secret, cipherText);
if (!equalBytes(mac, cipherMAC)) {
throw new Error("Invalid MAC");
}
const block = ecb(this.secret.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(cipherText);
return plain;
}
}
export class KeyManager extends BaseKeyManager {
private groups: Map<NodeHash, Group[]> = new Map();
private contacts: Map<NodeHash, Contact[]> = new Map();
private localIdentities: Map<NodeHash, LocalIdentity[]> = new Map();
public addGroup(group: Group): void {
const hash = group.secret.hash();
if (!this.groups.has(hash)) {
this.groups.set(hash, [group]);
} else {
this.groups.get(hash)!.push(group);
}
}
public addGroupSecret(name: string, secret?: Secret): void {
if (typeof secret === "undefined") {
secret = GroupSecret.fromName(name).secret;
} else if (typeof secret === "string") {
secret = hexToBytes(secret);
}
this.addGroup(new Group(name, new GroupSecret(secret)));
}
public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } {
const groupSecrets = this.groups.get(channelHash);
if (!groupSecrets) {
throw new Error("No group secrets for channel");
}
for (const group of groupSecrets) {
try {
const decrypted = group.decryptText(encrypted);
return { decrypted, group: group };
} catch (e) {
// Ignore and try next secret
}
}
throw new Error("Failed to decrypt group text with any known secret");
}
public decryptGroupData(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupData, group: Group } {
const groupSecrets = this.groups.get(channelHash);
if (!groupSecrets) {
throw new Error("No group secrets for channel");
}
for (const group of groupSecrets) {
try {
const decrypted = group.decryptData(encrypted);
return { decrypted, group };
} catch (e) {
// Ignore and try next secret
}
}
throw new Error("Failed to decrypt group data with any known secret");
}
public addContact(contact: Contact): void {
const hash = bytesToHex(contact.publicKey.slice(0, 1));
if (!this.contacts.has(hash)) {
this.contacts.set(hash, [contact]);
} else {
this.contacts.get(hash)!.push(contact);
}
}
public addIdentity(name: string, publicKey: Uint8Array | string): void {
if (typeof publicKey === "string") {
publicKey = hexToBytes(publicKey);
}
this.addContact({ name, publicKey });
}
public addLocalIdentity(seed: Secret): void {
const localIdentity = new LocalIdentity(seed);
const hash = localIdentity.hash();
if (!this.localIdentities.has(hash)) {
this.localIdentities.set(hash, [localIdentity]);
} else {
this.localIdentities.get(hash)!.push(localIdentity);
}
}
private tryDecrypt(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: Uint8Array, contact: Contact, identity: BaseIdentity } {
if (!this.localIdentities.has(dst)) {
throw new Error(`No local identities for destination ${dst}`);
}
const localIdentities = this.localIdentities.get(dst)!;
if (!this.contacts.has(src)) {
throw new Error(`No contacts for source ${src}`);
}
const contacts = this.contacts.get(src)!;
for (const localIdentity of localIdentities) {
for (const contact of contacts) {
const sharedSecret = localIdentity.calculateSharedSecret(contact.publicKey);
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
if (!equalBytes(mac, encrypted.cipherMAC)) {
continue; // Invalid MAC, try next combination
}
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(encrypted.cipherText);
if (plain.length < 8) {
continue; // Invalid plaintext, try next combination
}
return { decrypted: plain, contact, identity: localIdentity };
}
}
throw new Error("Failed to decrypt with any known identity/contact combination");
}
public decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, contact: Contact, identity: BaseIdentity } {
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
return {
decrypted: {
timestamp: reader.readTimestamp(),
requestType: reader.readByte(),
requestData: reader.readBytes(),
},
contact,
identity
}
}
public decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity } {
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
return {
decrypted: {
timestamp: reader.readTimestamp(),
responseData: reader.readBytes(),
},
contact,
identity
}
}
public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } {
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
const timestamp = reader.readTimestamp();
const flags = reader.readByte();
const textType = (flags >> 2) & 0x3F;
const attempt = flags & 0x03;
const message = new TextDecoder('utf-8').decode(reader.readBytes());
return {
decrypted: {
timestamp,
textType,
attempt,
message
},
contact,
identity
}
}
public decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity } {
if (!this.localIdentities.has(dst)) {
throw new Error(`No local identities for destination ${dst}`);
}
const localIdentities = this.localIdentities.get(dst)!;
const contact = { publicKey } as Contact; // Create a temporary contact object for MAC verification
for (const localIdentity of localIdentities) {
const sharedSecret = localIdentity.calculateSharedSecret(publicKey);
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
if (!equalBytes(mac, encrypted.cipherMAC)) {
continue; // Invalid MAC, try next identity
}
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(encrypted.cipherText);
if (plain.length < 8) {
continue; // Invalid plaintext, try next identity
}
const reader = new BufferReader(plain);
return {
decrypted: {
timestamp: reader.readTimestamp(),
data: reader.readBytes(),
},
contact,
identity: localIdentity
}
}
throw new Error("Failed to decrypt anonymous request with any known identity");
}
}