Files
meshcore.ts/src/crypto.ts
2026-03-10 17:35:15 +01:00

239 lines
7.5 KiB
TypeScript

import { ed25519, x25519 } from '@noble/curves/ed25519.js';
import { sha256 } from "@noble/hashes/sha2.js";
import { hmac } from '@noble/hashes/hmac.js';
import { ecb, ecb, encrypt } from '@noble/ciphers/aes.js';
import { bytesToHex, equalBytes, hexToBytes, encodedStringToBytes } from "./parser";
import { IPublicKey, ISharedSecret, IStaticSecret } from './crypto.types';
import { NodeHash } from './identity.types';
const PUBLIC_KEY_SIZE = 32;
const SEED_SIZE = 32;
const PRIVATE_KEY_SIZE = 32;
const HMAC_SIZE = 2;
const SHARED_SECRET_SIZE = 32;
const SIGNATURE_SIZE = 64;
const STATIC_SECRET_SIZE = 32;
export class PublicKey implements IPublicKey {
public key: Uint8Array;
constructor(key: Uint8Array | string) {
if (typeof key === 'string') {
this.key = encodedStringToBytes(key, PUBLIC_KEY_SIZE);
} else if (key instanceof Uint8Array) {
this.key = key;
} else {
throw new Error('Invalid type for PublicKey constructor');
}
}
public toHash(): NodeHash {
return sha256.create().update(this.key).digest()[0] as NodeHash;
}
public toBytes(): Uint8Array {
return this.key;
}
public toString(): string {
return bytesToHex(this.key);
}
public equals(other: PublicKey | Uint8Array | string): boolean {
let otherKey: Uint8Array;
if (other instanceof PublicKey) {
otherKey = other.toBytes();
} else if (other instanceof Uint8Array) {
otherKey = other;
} else if (typeof other === 'string') {
otherKey = encodedStringToBytes(other, PUBLIC_KEY_SIZE);
} else {
throw new Error('Invalid type for PublicKey comparison');
}
return equalBytes(this.key, otherKey);
}
public verify(message: Uint8Array, signature: Uint8Array): boolean {
if (signature.length !== SIGNATURE_SIZE) {
throw new Error(`Invalid signature length: expected ${SIGNATURE_SIZE} bytes, got ${signature.length}`);
}
return ed25519.verify(signature, message, this.key);
}
}
export class PrivateKey {
private secretKey: Uint8Array;
private publicKey: PublicKey;
constructor(seed: Uint8Array | string) {
if (typeof seed === 'string') {
seed = encodedStringToBytes(seed, SEED_SIZE);
}
if (seed.length !== SEED_SIZE) {
throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`);
}
const { secretKey, publicKey } = ed25519.keygen(seed); // Validate seed by generating keys
this.secretKey = secretKey;
this.publicKey = new PublicKey(publicKey);
}
public toPublicKey(): PublicKey {
return this.publicKey;
}
public toBytes(): Uint8Array {
return this.secretKey;
}
public toString(): string {
return bytesToHex(this.secretKey);
}
public sign(message: Uint8Array): Uint8Array {
return ed25519.sign(message, this.secretKey);
}
public calculateSharedSecret(other: PublicKey | Uint8Array | string): Uint8Array {
let otherPublicKey: PublicKey;
if (other instanceof PublicKey) {
otherPublicKey = other;
} else if (other instanceof Uint8Array) {
otherPublicKey = new PublicKey(other);
} else if (typeof other === 'string') {
otherPublicKey = new PublicKey(other);
} else {
throw new Error('Invalid type for calculateSharedSecret comparison');
}
return x25519.getSharedSecret(this.secretKey, otherPublicKey.toBytes());
}
static generate(): PrivateKey {
const { secretKey } = ed25519.keygen(); // Ensure ed25519 is initialized
return new PrivateKey(secretKey);
}
}
export class SharedSecret implements ISharedSecret {
private secret: Uint8Array;
constructor(secret: Uint8Array) {
if (secret.length === SHARED_SECRET_SIZE / 2) {
// Zero pad to the left if the secret is too short (e.g. from x25519)
const padded = new Uint8Array(SHARED_SECRET_SIZE);
padded.set(secret, SHARED_SECRET_SIZE - secret.length);
secret = padded;
}
if (secret.length !== SHARED_SECRET_SIZE) {
throw new Error(`Invalid shared secret length: expected ${SHARED_SECRET_SIZE} bytes, got ${secret.length}`);
}
this.secret = secret;
}
public toHash(): NodeHash {
return this.secret[0] as NodeHash;
}
public toBytes(): Uint8Array {
return this.secret;
}
public toString(): string {
return bytesToHex(this.secret);
}
public decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array {
if (hmac.length !== HMAC_SIZE) {
throw new Error(`Invalid HMAC length: expected ${HMAC_SIZE} bytes, got ${hmac.length}`);
}
const expectedHmac = this.calculateHmac(ciphertext);
if (!equalBytes(hmac, expectedHmac)) {
throw new Error(`Invalid HMAC: decryption failed: expected ${bytesToHex(expectedHmac)}, got ${bytesToHex(hmac)}`);
}
const cipher = ecb(this.secret.slice(0, 16), { disablePadding: true });
const plaintext = new Uint8Array(ciphertext.length);
for (let i = 0; i < ciphertext.length; i += 16) {
const block = ciphertext.slice(i, i + 16);
const dec = cipher.decrypt(block);
plaintext.set(dec, i);
}
// Remove trailing null bytes (0x00) due to padding
let end = plaintext.length;
while (end > 0 && plaintext[end - 1] === 0) {
end--;
}
return plaintext.slice(0, end);
}
private zeroPad(data: Uint8Array): Uint8Array {
if (data.length % 16 === 0) {
return data;
}
const padded = new Uint8Array(Math.ceil(data.length / 16) * 16);
padded.set(data);
return padded;
}
public encrypt(data: Uint8Array): { hmac: Uint8Array, ciphertext: Uint8Array } {
const key = this.secret.slice(0, 16);
const cipher = ecb(key, { disablePadding: true });
const fullBlocks = Math.floor(data.length / 16);
const remaining = data.length % 16;
const ciphertext = new Uint8Array((fullBlocks + (remaining > 0 ? 1 : 0)) * 16);
for (let i = 0; i < fullBlocks; i++) {
const block = data.slice(i * 16, (i + 1) * 16);
const enc = cipher.encrypt(block);
ciphertext.set(enc, i * 16);
}
if (remaining > 0) {
const lastBlock = new Uint8Array(16);
lastBlock.set(data.slice(fullBlocks * 16));
const enc = cipher.encrypt(lastBlock);
ciphertext.set(enc, fullBlocks * 16);
}
const hmac = this.calculateHmac(ciphertext);
return { hmac, ciphertext };
}
private calculateHmac(data: Uint8Array): Uint8Array {
return hmac(sha256, this.secret, data).slice(0, HMAC_SIZE);
}
static fromName(name: string): SharedSecret {
if (name === "Public") {
return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72"));
} 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 SharedSecret(hash.slice(0, SHARED_SECRET_SIZE));
}
}
export class StaticSecret implements IStaticSecret {
private secret: Uint8Array;
constructor(secret: Uint8Array | string) {
if (typeof secret === 'string') {
secret = encodedStringToBytes(secret, STATIC_SECRET_SIZE);
}
if (secret.length !== STATIC_SECRET_SIZE) {
throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`);
}
this.secret = secret;
}
public publicKey(): IPublicKey {
const publicKey = x25519.getPublicKey(this.secret);
return new PublicKey(publicKey);
}
public diffieHellman(otherPublicKey: IPublicKey): SharedSecret {
const sharedSecret = x25519.getSharedSecret(this.secret, otherPublicKey.toBytes());
return new SharedSecret(sharedSecret);
}
}