diff --git a/package.json b/package.json index f94944e..0b9bc12 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "commander": "^14.0.0", "conf": "^14.0.0", "decimal.js": "^10.6.0", + "eciesjs": "^0.4.15", "inquirer": "^12.7.0", "js-sha3": "^0.9.3", "ky": "^1.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cb4a6f..e0f2901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: decimal.js: specifier: ^10.6.0 version: 10.6.0 + eciesjs: + specifier: ^0.4.15 + version: 0.4.15 inquirer: specifier: ^12.7.0 version: 12.7.0(@types/node@24.0.13) @@ -352,6 +355,15 @@ packages: integrity: sha512-I3Z+6SWUoaljh3TBzCnCxjlUyN8tA+NAk5L6m9IxvCf1BENQTePzPMis97CoN/iMW1St3WN+AWCCRp+TTBRiDg==, } + "@ecies/ciphers@0.2.4": + resolution: + { + integrity: sha512-t+iX+Wf5nRKyNzk8dviW3Ikb/280+aEJAnw9YXvCp2tYGPSkMki+NRY+8aNLmVFv3eNtMdvViPNOPxS8SZNP+w==, + } + engines: { bun: ">=1", deno: ">=2", node: ">=16" } + peerDependencies: + "@noble/ciphers": ^1.0.0 + "@effect/platform@0.71.7": resolution: { @@ -1182,6 +1194,13 @@ packages: integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==, } + "@noble/ciphers@1.3.0": + resolution: + { + integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==, + } + engines: { node: ^14.21.3 || >=16 } + "@noble/curves@1.4.2": resolution: { @@ -2213,6 +2232,13 @@ packages: } engines: { node: ">= 0.4" } + eciesjs@0.4.15: + resolution: + { + integrity: sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==, + } + engines: { bun: ">=1", deno: ">=2", node: ">=16" } + effect@3.16.7: resolution: { @@ -4631,6 +4657,10 @@ snapshots: dependencies: "@chainsafe/is-ip": 2.1.0 + "@ecies/ciphers@0.2.4(@noble/ciphers@1.3.0)": + dependencies: + "@noble/ciphers": 1.3.0 + "@effect/platform@0.71.7(effect@3.16.7)": dependencies: effect: 3.16.7 @@ -5215,6 +5245,8 @@ snapshots: "@tybys/wasm-util": 0.9.0 optional: true + "@noble/ciphers@1.3.0": {} + "@noble/curves@1.4.2": dependencies: "@noble/hashes": 1.4.0 @@ -5866,6 +5898,13 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eciesjs@0.4.15: + dependencies: + "@ecies/ciphers": 0.2.4(@noble/ciphers@1.3.0) + "@noble/ciphers": 1.3.0 + "@noble/curves": 1.9.2 + "@noble/hashes": 1.8.0 + effect@3.16.7: dependencies: "@standard-schema/spec": 1.0.0 diff --git a/src/commands/blind.ts b/src/commands/blind.ts new file mode 100644 index 0000000..15f71cf --- /dev/null +++ b/src/commands/blind.ts @@ -0,0 +1,68 @@ +import { password } from "@inquirer/prompts"; +import { type } from "arktype"; +import { decrypt, ECIES_CONFIG, encrypt, PrivateKey } from "eciesjs"; +import { mayFail } from "ts-handling"; +import { decode } from "../encoding.js"; +import { exit, program } from "./cli.js"; + +ECIES_CONFIG.ellipticCurve = "x25519"; + +const isHex = (value: string) => + value.length % 2 === 0 && /^[0-9a-f]+$/.test(value); + +const Data = type("string > 0").pipe( + (v) => new Uint8Array(Buffer.from(v, isHex(v) ? "hex" : "utf8")), +); +const Key = type(/^[a-f0-9]{64}$/).pipe((v) => Buffer.from(v, "hex")); + +const Hex = type(/^[a-f0-9]+$/) + .narrow((v, ctx) => v.length % 2 === 0 || ctx.mustBe("even length")) + .pipe((v) => Buffer.from(v, "hex")); + +const blind = program.command("blind").description("Blind and unblind data"); + +blind + .command("key") + .description("Creates a new blind key pair") + .action(() => { + const sk = new PrivateKey(undefined, "x25519"); + console.log("Public:", sk.publicKey.toHex()); + console.log("Private:", sk.toHex()); + }); + +blind + .command("encode") + .description("Blinds data") + .argument("data", "The data to blind") + .argument("blinding", "The x25519 public key to use to blind the data") + .action(($data: string, $blinding: string) => { + const data = Data($data); + if (data instanceof type.errors) return exit(`data ${data.summary}`); + + const blinding = Key($blinding); + if (blinding instanceof type.errors) + return exit(`blinding ${blinding.summary}`); + + console.log(encrypt(blinding, data).toString("hex")); + }); + +blind + .command("decode") + .description("Unblinds data") + .argument("encoded", "The encoded data to unblind") + .action(async ($encoded: string) => { + const encoded = Hex($encoded); + if (encoded instanceof type.errors) + return exit(`encoded ${encoded.summary}`); + + const $privateKey = await password({ message: "Enter your private key" }); + if (!$privateKey) return exit("No private key provided"); + const privateKey = Key($privateKey); + if (privateKey instanceof type.errors) + return exit(`private key ${privateKey.summary}`); + + const decrypted = mayFail(() => decrypt(privateKey, encoded)); + if (!decrypted.ok) return exit("wrong private key"); + + console.log(decode(decrypted.data)); + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 04864d8..5e16e04 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,5 @@ import { program } from "./cli.js"; +import "./blind.js"; import "./config.js"; import "./payload.js"; import "./wallet.js"; diff --git a/src/encoding.ts b/src/encoding.ts new file mode 100644 index 0000000..5524144 --- /dev/null +++ b/src/encoding.ts @@ -0,0 +1,49 @@ +import { TextDecoder } from "util"; + +type Encoding = "hex" | "utf8"; + +const looksLikeHex = (data: Buffer) => { + if (data.length === 0 || data.length % 2 !== 0) return false; + + for (const b of data) + if ( + !( + (b >= 0x30 && b <= 0x39) || // '0'–'9' + (b >= 0x41 && b <= 0x46) || // 'A'–'F' + (b >= 0x61 && b <= 0x66) // 'a'–'f' + ) + ) + return false; + + return true; +}; + +const isValidUtf8 = (data: Buffer) => { + let decoded: string; + + try { + decoded = new TextDecoder("utf-8", { fatal: true }).decode(data); + } catch { + return false; + } + + // eslint-disable-next-line no-control-regex + return !/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(decoded); +}; + +const detectEncoding = (data: Buffer): Encoding => { + if (looksLikeHex(data)) { + try { + const decoded = Buffer.from(data.toString("ascii"), "hex"); + if (isValidUtf8(decoded)) return "hex"; + } catch {} + } + + if (isValidUtf8(data)) return "utf8"; + + return "hex"; +}; + +const decode = (data: Buffer): string => data.toString(detectEncoding(data)); + +export { decode };