From a040bfeebb38a4884021e333a1fa12bc71bb7dab Mon Sep 17 00:00:00 2001 From: Adrien Beraud Date: Mon, 26 Oct 2026 16:11:00 -0400 Subject: [PATCH] nodejs: add crypto APIs and relocatable npm prebuilds --- nodejs/.gitignore | 4 +- nodejs/CMakeLists.txt | 2 +- nodejs/addon.cpp | 67 ++++--- nodejs/crypto.cpp | 306 ++++++++++++++++++++++++++++++ nodejs/crypto.h | 26 +++ nodejs/crypto.js | 58 ++++++ nodejs/index.d.ts | 44 +++++ nodejs/index.js | 16 +- nodejs/package-lock.json | 15 +- nodejs/package.json | 13 +- nodejs/scripts/bundle.js | 104 ++++++++++ nodejs/scripts/dependencies.cmake | 39 ++++ nodejs/scripts/test-package.js | 39 ++++ 13 files changed, 703 insertions(+), 30 deletions(-) create mode 100644 nodejs/crypto.cpp create mode 100644 nodejs/crypto.h create mode 100644 nodejs/crypto.js create mode 100644 nodejs/scripts/bundle.js create mode 100644 nodejs/scripts/dependencies.cmake create mode 100644 nodejs/scripts/test-package.js diff --git a/nodejs/.gitignore b/nodejs/.gitignore index b51ea715..327c9736 100644 --- a/nodejs/.gitignore +++ b/nodejs/.gitignore @@ -1,2 +1,4 @@ node_modules/ -build/ \ No newline at end of file +build*/ +prebuilds/ +dist/ \ No newline at end of file diff --git a/nodejs/CMakeLists.txt b/nodejs/CMakeLists.txt index 22cbdb1a..70fcd2ad 100644 --- a/nodejs/CMakeLists.txt +++ b/nodejs/CMakeLists.txt @@ -20,7 +20,7 @@ execute_process( COMMAND_ERROR_IS_FATAL ANY ) -add_library(opendht_node MODULE addon.cpp ${CMAKE_JS_SRC}) +add_library(opendht_node MODULE addon.cpp crypto.cpp ${CMAKE_JS_SRC}) target_compile_definitions(opendht_node PRIVATE NAPI_VERSION=8 NAPI_CPP_EXCEPTIONS) target_include_directories(opendht_node PRIVATE ${CMAKE_JS_INC} diff --git a/nodejs/addon.cpp b/nodejs/addon.cpp index 8c858130..a52607dc 100644 --- a/nodejs/addon.cpp +++ b/nodejs/addon.cpp @@ -1,5 +1,6 @@ #include #include +#include "crypto.h" #include #include #include @@ -14,18 +15,7 @@ namespace { constexpr size_t maxQueuedEvents = 1024; constexpr size_t maxResultBytes = 16 * 1024 * 1024; -template -Napi::Value -guarded(const Napi::CallbackInfo& info, Callback&& callback) -{ - try { - return callback(); - } catch (const Napi::Error&) { - throw; - } catch (const std::exception& error) { - throw Napi::Error::New(info.Env(), error.what()); - } -} +using opendht_node::guarded; uint32_t unsignedNumber(Napi::Value value, uint32_t maximum, const char* name) @@ -88,6 +78,9 @@ struct ValueData , seq(value.seq) , data(value.data) , userType(value.user_type) + , owner(value.owner ? value.owner->getLongId().toString() : "") + , isSigned(value.isSigned()) + , isEncrypted(value.isEncrypted()) {} Napi::Object toJS(Napi::Env env) const @@ -98,16 +91,22 @@ struct ValueData result.Set("seq", Napi::Number::New(env, seq)); result.Set("data", Napi::Buffer::Copy(env, data.data(), data.size())); result.Set("userType", Napi::String::New(env, userType)); + result.Set("owner", owner.empty() ? Napi::Value(env.Null()) : Napi::Value(Napi::String::New(env, owner))); + result.Set("isSigned", Napi::Boolean::New(env, isSigned)); + result.Set("isEncrypted", Napi::Boolean::New(env, isEncrypted)); return result; } - size_t size() const { return sizeof(ValueData) + data.size() + userType.size(); } + size_t size() const { return sizeof(ValueData) + data.size() + userType.size() + owner.size(); } uint64_t id; uint16_t type; uint16_t seq; dht::Blob data; std::string userType; + std::string owner; + bool isSigned; + bool isEncrypted; }; struct Event @@ -399,6 +398,7 @@ struct RunnerState std::atomic_size_t queuedBytes {0}; bool envClosing = false; bool started = false; + bool hasIdentity = false; bool finished = false; uint64_t nextOperation = 1; std::string closeError; @@ -436,6 +436,8 @@ public: InstanceMethod("bootstrap", &Runner::bootstrap), InstanceMethod("get", &Runner::get), InstanceMethod("put", &Runner::put), + InstanceMethod("putSigned", &Runner::putSigned), + InstanceMethod("putEncrypted", &Runner::putEncrypted), InstanceMethod("listen", &Runner::listen), InstanceMethod("cancelListen", &Runner::cancelListen), InstanceMethod("ref", &Runner::ref), @@ -462,9 +464,12 @@ private: config.dht_config.node_config.network = unsignedNumber(options.Get("network"), UINT32_MAX, "network"); if (options.Has("persistPath")) config.dht_config.node_config.persist_path = text(options.Get("persistPath"), "persistPath"); + if (options.Has("identity")) + config.dht_config.id = opendht_node::readIdentity(options.Get("identity")); const auto service = std::to_string(port); state_->runner.run(ipv4.c_str(), ipv6.c_str(), service.c_str(), config); state_->started = true; + state_->hasIdentity = bool(config.dht_config.id.first); state_->dispatcher.Ref(info.Env()); return info.Env().Undefined(); }); @@ -560,10 +565,24 @@ private: }); } - Napi::Value put(const Napi::CallbackInfo& info) + Napi::Value put(const Napi::CallbackInfo& info) { return putValue(info, PutMode::Plain); } + + Napi::Value putSigned(const Napi::CallbackInfo& info) { return putValue(info, PutMode::Signed); } + + Napi::Value putEncrypted(const Napi::CallbackInfo& info) { return putValue(info, PutMode::Encrypted); } + + enum class PutMode { Plain, Signed, Encrypted }; + + Napi::Value putValue(const Napi::CallbackInfo& info, PutMode mode) { return guarded(info, [&]() -> Napi::Value { state_->requireRunning(); + if (mode != PutMode::Plain && !state_->hasIdentity) + throw Napi::Error::New(info.Env(), "Signed and encrypted puts require an identity"); + std::shared_ptr recipient; + if (mode == PutMode::Encrypted) { + recipient = opendht_node::readEncryptionKey(info[3]); + } const auto key = infoHash(info[0]); if (!info[1].IsTypedArray() || info[1].As().TypedArrayType() != napi_uint8_array) throw Napi::TypeError::New(info.Env(), "data must be a Uint8Array"); @@ -594,15 +613,16 @@ private: state_->operations.emplace(operation, PendingOperation {deferred, false, {}}); const std::weak_ptr state = state_; try { - state_->runner.put(key, - value, - dht::DoneCallbackSimple([state, operation](bool ok) { - if (const auto owner = state.lock()) - owner->send( - std::make_unique(Event {Event::Kind::Done, operation, ok, {}})); - }), - dht::time_point::max(), - permanent); + dht::DoneCallbackSimple done = [state, operation](bool ok) { + if (const auto owner = state.lock()) + owner->send(std::make_unique(Event {Event::Kind::Done, operation, ok, {}})); + }; + if (mode == PutMode::Signed) + state_->runner.putSigned(key, value, std::move(done), permanent); + else if (mode == PutMode::Encrypted) + state_->runner.putEncrypted(key, recipient, value, std::move(done), permanent); + else + state_->runner.put(key, value, std::move(done), dht::time_point::max(), permanent); } catch (...) { state_->operations.erase(operation); throw; @@ -706,6 +726,7 @@ hash(const Napi::CallbackInfo& info) Napi::Object initialize(Napi::Env env, Napi::Object exports) { + opendht_node::initializeCrypto(env, exports); exports.Set("NativeRunner", Runner::define(env)); exports.Set("hash", Napi::Function::New(env, hash)); exports.Set("version", Napi::String::New(env, dht::version())); diff --git a/nodejs/crypto.cpp b/nodejs/crypto.cpp new file mode 100644 index 00000000..4734b694 --- /dev/null +++ b/nodejs/crypto.cpp @@ -0,0 +1,306 @@ +#include "crypto.h" + +#include +#include +#include + +namespace opendht_node { +namespace { + +using PublicKey = std::shared_ptr; +using PrivateKey = std::shared_ptr; +using Certificate = std::shared_ptr; +using Identity = dht::crypto::Identity; +using Handle = std::variant; + +constexpr napi_type_tag cryptoTag {0x37cdf3e4648e4cb5ULL, 0x964fb031062c245aULL}; + +Handle& +handle(Napi::Value value) +{ + bool matches = false; + if (!value.IsExternal() || napi_check_object_type_tag(value.Env(), value, &cryptoTag, &matches) != napi_ok + || !matches) + throw Napi::TypeError::New(value.Env(), "Invalid OpenDHT crypto handle"); + return *value.As>().Data(); +} + +template +Type +read(Napi::Value value) +{ + if (auto result = std::get_if(&handle(value))) + return *result; + throw Napi::TypeError::New(value.Env(), "Wrong OpenDHT crypto handle type"); +} + +Napi::Value +wrap(Napi::Env env, Handle value) +{ + auto owner = std::make_unique(std::move(value)); + auto result = Napi::External::New(env, owner.get(), [](Napi::Env, Handle* data) { delete data; }); + owner.release(); + if (napi_type_tag_object(env, result, &cryptoTag) != napi_ok) + throw Napi::Error::New(env, "Could not tag OpenDHT crypto handle"); + return result; +} + +dht::Blob +bytes(Napi::Value value) +{ + if (!value.IsTypedArray() || value.As().TypedArrayType() != napi_uint8_array) + throw Napi::TypeError::New(value.Env(), "Expected a Uint8Array"); + auto array = value.As(); + if (array.ElementLength() > 16 * 1024 * 1024) + throw Napi::RangeError::New(value.Env(), "Crypto input exceeds 16 MiB"); + dht::Blob result(array.ElementLength()); + if (!result.empty()) + std::copy_n(array.Data(), result.size(), result.data()); + return result; +} + +std::string +string(Napi::Value value) +{ + if (!value.IsString()) + throw Napi::TypeError::New(value.Env(), "Expected a string"); + auto result = value.As().Utf8Value(); + if (result.find('\0') != std::string::npos) + throw Napi::TypeError::New(value.Env(), "Crypto strings must not contain NUL bytes"); + return result; +} + +Napi::Buffer +buffer(Napi::Env env, const dht::Blob& value) +{ + return Napi::Buffer::Copy(env, value.data(), value.size()); +} + +PublicKey +publicKey(const Handle& value) +{ + if (auto key = std::get_if(&value)) + return *key; + if (auto key = std::get_if(&value)) + return (*key)->getSharedPublicKey(); + if (auto certificate = std::get_if(&value)) + return (*certificate)->getSharedPublicKey(); + return std::get(value).second->getSharedPublicKey(); +} + +class GenerateWorker : public Napi::AsyncWorker +{ +public: + GenerateWorker(Napi::Env env, bool identity, std::string name, bool rsa, unsigned bits) + : Napi::AsyncWorker(env, "OpenDHT crypto generation") + , deferred_(Napi::Promise::Deferred::New(env)) + , identity_(identity) + , name_(std::move(name)) + , rsa_(rsa) + , bits_(bits) + {} + + Napi::Promise promise() const { return deferred_.Promise(); } + + void Execute() override + { + try { + if (identity_) + result_ = rsa_ ? dht::crypto::generateIdentity(name_, {}, bits_) + : dht::crypto::generateEcIdentity(name_); + else + result_ = std::make_shared(rsa_ ? dht::crypto::PrivateKey::generate(bits_) + : dht::crypto::PrivateKey::generateEC()); + } catch (const std::exception& error) { + SetError(error.what()); + } + } + + void OnOK() override { deferred_.Resolve(wrap(Env(), std::move(result_))); } + void OnError(const Napi::Error& error) override { deferred_.Reject(error.Value()); } + +private: + Napi::Promise::Deferred deferred_; + bool identity_; + std::string name_; + bool rsa_; + unsigned bits_; + Handle result_; +}; + +Napi::Value +generate(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + const auto kind = string(info[0]); + const auto name = string(info[1]); + const auto algorithm = string(info[2]); + if (algorithm != "rsa" && algorithm != "ec") + throw Napi::TypeError::New(info.Env(), "algorithm must be rsa or ec"); + if (!info[3].IsNumber()) + throw Napi::TypeError::New(info.Env(), "bits must be a number"); + const auto bits = info[3].As().DoubleValue(); + if (!std::isfinite(bits) || std::floor(bits) != bits || bits < 2048 || bits > 8192) + throw Napi::RangeError::New(info.Env(), "RSA bits must be an integer between 2048 and 8192"); + if (kind != "private" && kind != "identity") + throw Napi::TypeError::New(info.Env(), "Invalid crypto generation type"); + auto worker = std::make_unique(info.Env(), kind == "identity", name, algorithm == "rsa", bits); + auto promise = worker->promise(); + worker->Queue(); + worker.release(); + return promise; + }); +} + +Napi::Value +import(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + const auto kind = string(info[0]); + auto data = bytes(info[1]); + if (data.empty()) + throw Napi::TypeError::New(info.Env(), "Crypto imports must not be empty"); + if (kind == "private") + return wrap(info.Env(), std::make_shared(data, string(info[2]))); + if (kind == "public") + return wrap(info.Env(), std::make_shared(data)); + if (kind == "certificate") + return wrap(info.Env(), std::make_shared(data)); + throw Napi::TypeError::New(info.Env(), "Invalid crypto import type"); + }); +} + +Napi::Value +serialize(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + auto& value = handle(info[0]); + if (auto key = std::get_if(&value)) + return buffer(info.Env(), (*key)->serialize(string(info[1]))); + if (auto certificate = std::get_if(&value)) + return buffer(info.Env(), (*certificate)->getPacked()); + dht::Blob result; + readPublicKey(info[0])->pack(result); + return buffer(info.Env(), result); + }); +} + +Napi::Value +getPublicKey(const Napi::CallbackInfo& info) +{ + return guarded(info, [&] { return wrap(info.Env(), publicKey(handle(info[0]))); }); +} + +Napi::Value +getId(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + const auto key = publicKey(handle(info[0])); + if (!info[1].IsBoolean()) + throw Napi::TypeError::New(info.Env(), "Expected a boolean"); + return Napi::String::New(info.Env(), + info[1].As().Value() ? key->getLongId().toString() + : key->getId().toString()); + }); +} + +Napi::Value +sign(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + return buffer(info.Env(), read(info[0])->sign(bytes(info[1]))); + }); +} + +Napi::Value +verify(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + return Napi::Boolean::New(info.Env(), readPublicKey(info[0])->checkSignature(bytes(info[1]), bytes(info[2]))); + }); +} + +Napi::Value +encrypt(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + const auto key = readEncryptionKey(info[0]); + return buffer(info.Env(), key->encrypt(bytes(info[1]))); + }); +} + +Napi::Value +decrypt(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + return buffer(info.Env(), read(info[0])->decrypt(bytes(info[1]))); + }); +} + +Napi::Value +identity(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + auto key = read(info[0]); + auto certificate = read(info[1]); + if (key->getPublicKey().getLongId() != certificate->getLongId()) + throw Napi::TypeError::New(info.Env(), "Identity key and certificate do not match"); + return wrap(info.Env(), Identity {std::move(key), std::move(certificate)}); + }); +} + +Napi::Value +identityPart(const Napi::CallbackInfo& info) +{ + return guarded(info, [&]() -> Napi::Value { + const auto value = readIdentity(info[0]); + if (!info[1].IsBoolean()) + throw Napi::TypeError::New(info.Env(), "Expected a boolean"); + return info[1].As().Value() ? wrap(info.Env(), value.first) : wrap(info.Env(), value.second); + }); +} + +} // namespace + +dht::crypto::Identity +readIdentity(Napi::Value value) +{ + return read(value); +} +std::shared_ptr +readPublicKey(Napi::Value value) +{ + return read(value); +} + +std::shared_ptr +readEncryptionKey(Napi::Value value) +{ + const auto key = readPublicKey(value); + const auto algorithm = gnutls_pubkey_get_pk_algorithm(key->pk, nullptr); + if (algorithm != GNUTLS_PK_RSA +#if GNUTLS_VERSION_NUMBER >= 0x030804 + && algorithm != GNUTLS_PK_RSA_OAEP +#endif + ) + throw Napi::TypeError::New(value.Env(), "Encryption requires an RSA public key"); + return key; +} + +void +initializeCrypto(Napi::Env env, Napi::Object exports) +{ + exports.Set("cryptoGenerate", Napi::Function::New(env, generate)); + exports.Set("cryptoImport", Napi::Function::New(env, import)); + exports.Set("cryptoSerialize", Napi::Function::New(env, serialize)); + exports.Set("cryptoPublicKey", Napi::Function::New(env, getPublicKey)); + exports.Set("cryptoId", Napi::Function::New(env, getId)); + exports.Set("cryptoSign", Napi::Function::New(env, sign)); + exports.Set("cryptoVerify", Napi::Function::New(env, verify)); + exports.Set("cryptoEncrypt", Napi::Function::New(env, encrypt)); + exports.Set("cryptoDecrypt", Napi::Function::New(env, decrypt)); + exports.Set("cryptoIdentity", Napi::Function::New(env, identity)); + exports.Set("cryptoIdentityPart", Napi::Function::New(env, identityPart)); +} + +} // namespace opendht_node \ No newline at end of file diff --git a/nodejs/crypto.h b/nodejs/crypto.h new file mode 100644 index 00000000..254c732b --- /dev/null +++ b/nodejs/crypto.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace opendht_node { + +template +Napi::Value +guarded(const Napi::CallbackInfo& info, Callback&& callback) +{ + try { + return callback(); + } catch (const Napi::Error&) { + throw; + } catch (const std::exception& error) { + throw Napi::Error::New(info.Env(), error.what()); + } +} + +dht::crypto::Identity readIdentity(Napi::Value value); +std::shared_ptr readPublicKey(Napi::Value value); +std::shared_ptr readEncryptionKey(Napi::Value value); +void initializeCrypto(Napi::Env env, Napi::Object exports); + +} // namespace opendht_node \ No newline at end of file diff --git a/nodejs/crypto.js b/nodejs/crypto.js new file mode 100644 index 00000000..d5704e02 --- /dev/null +++ b/nodejs/crypto.js @@ -0,0 +1,58 @@ +'use strict'; + +module.exports = native => { + const handles = new WeakMap(); + + function handle(value, Type) { + if (!(value instanceof Type) || !handles.has(value)) throw new TypeError(`Expected ${Type.name}`); + return handles.get(value); + } + + function wrap(Type, nativeHandle) { + const result = Object.create(Type.prototype); + handles.set(result, nativeHandle); + return result; + } + + function generate(kind, name, options = {}) { + if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Invalid crypto options'); + return native.cryptoGenerate(kind, name, options.algorithm ?? 'ec', options.bits ?? 2048); + } + + class PublicKey { + constructor(data) { handles.set(this, native.cryptoImport('public', data)); } + serialize() { return native.cryptoSerialize(handle(this, PublicKey)); } + getId() { return native.cryptoId(handle(this, PublicKey), false); } + getLongId() { return native.cryptoId(handle(this, PublicKey), true); } + checkSignature(data, signature) { return native.cryptoVerify(handle(this, PublicKey), data, signature); } + encrypt(data) { return native.cryptoEncrypt(handle(this, PublicKey), data); } + } + + class PrivateKey { + constructor(data, password = '') { handles.set(this, native.cryptoImport('private', data, password)); } + static generate(options) { return generate('private', '', options).then(value => wrap(PrivateKey, value)); } + serialize(password = '') { return native.cryptoSerialize(handle(this, PrivateKey), password); } + getPublicKey() { return wrap(PublicKey, native.cryptoPublicKey(handle(this, PrivateKey))); } + sign(data) { return native.cryptoSign(handle(this, PrivateKey), data); } + decrypt(data) { return native.cryptoDecrypt(handle(this, PrivateKey), data); } + } + + class Certificate { + constructor(data) { handles.set(this, native.cryptoImport('certificate', data)); } + serialize() { return native.cryptoSerialize(handle(this, Certificate)); } + getPublicKey() { return wrap(PublicKey, native.cryptoPublicKey(handle(this, Certificate))); } + getId() { return native.cryptoId(handle(this, Certificate), false); } + getLongId() { return native.cryptoId(handle(this, Certificate), true); } + } + + class Identity { + constructor(privateKey, certificate) { + handles.set(this, native.cryptoIdentity(handle(privateKey, PrivateKey), handle(certificate, Certificate))); + } + static generate(name = 'dhtnode', options) { return generate('identity', name, options).then(value => wrap(Identity, value)); } + get privateKey() { return wrap(PrivateKey, native.cryptoIdentityPart(handle(this, Identity), true)); } + get certificate() { return wrap(Certificate, native.cryptoIdentityPart(handle(this, Identity), false)); } + } + + return { PublicKey, PrivateKey, Certificate, Identity, handle }; +}; \ No newline at end of file diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index cedd2a2f..bd95b1ca 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -4,12 +4,51 @@ import { EventEmitter } from 'node:events'; export const version: string; export function hash(data: string | Uint8Array): string; +export interface KeyOptions { + algorithm?: 'ec' | 'rsa'; + bits?: number; +} + +export class PublicKey { + constructor(data: Uint8Array); + serialize(): Buffer; + getId(): string; + getLongId(): string; + checkSignature(data: Uint8Array, signature: Uint8Array): boolean; + encrypt(data: Uint8Array): Buffer; +} + +export class PrivateKey { + constructor(data: Uint8Array, password?: string); + static generate(options?: KeyOptions): Promise; + serialize(password?: string): Buffer; + getPublicKey(): PublicKey; + sign(data: Uint8Array): Buffer; + decrypt(data: Uint8Array): Buffer; +} + +export class Certificate { + constructor(data: Uint8Array); + serialize(): Buffer; + getPublicKey(): PublicKey; + getId(): string; + getLongId(): string; +} + +export class Identity { + constructor(privateKey: PrivateKey, certificate: Certificate); + static generate(name?: string, options?: KeyOptions): Promise; + readonly privateKey: PrivateKey; + readonly certificate: Certificate; +} + export interface RunOptions { port?: number; ipv4?: string; ipv6?: string; network?: number; persistPath?: string; + identity?: Identity; } export interface PutOptions { @@ -25,6 +64,9 @@ export interface Value { seq: number; data: Buffer; userType: string; + owner: string | null; + isSigned: boolean; + isEncrypted: boolean; } export interface Listener { @@ -39,6 +81,8 @@ export class DhtRunner extends EventEmitter implements AsyncDisposable { bootstrap(host: string, port: number): void; get(key: string): Promise; put(key: string, data: Uint8Array, options?: PutOptions): Promise; + putSigned(key: string, data: Uint8Array, options?: PutOptions): Promise; + putEncrypted(key: string, data: Uint8Array, recipient: PublicKey, options?: PutOptions): Promise; listen(key: string, callback: (value: Value, expired: boolean) => void): Listener; close(): Promise; ref(): this; diff --git a/nodejs/index.js b/nodejs/index.js index f282544d..211fcdae 100644 --- a/nodejs/index.js +++ b/nodejs/index.js @@ -1,7 +1,10 @@ 'use strict'; -const native = require(process.env.OPENDHT_NODE_BINDING || './build/Release/opendht.node'); +const native = process.env.OPENDHT_NODE_BINDING + ? require(process.env.OPENDHT_NODE_BINDING) + : require('node-gyp-build')(__dirname); const { EventEmitter } = require('node:events'); +const { PublicKey, PrivateKey, Certificate, Identity, handle } = require('./crypto')(native); function errorReporter(owner) { return error => { owner.deref()?.emit('error', error); }; @@ -38,13 +41,20 @@ class DhtRunner extends EventEmitter { off(event, listener) { return this.removeListener(event, listener); } removeAllListeners(...args) { super.removeAllListeners(...args); this.#updateOwner(); return this; } - run(options) { this.#native.run(options); } + run(options) { + if (options?.identity !== undefined) options = { ...options, identity: handle(options.identity, Identity) }; + this.#native.run(options); + } isRunning() { return this.#native.isRunning(); } getBoundPort(family = 4) { return this.#native.getBoundPort(family); } getNodeId() { return this.#native.getNodeId(); } bootstrap(host, port) { this.#native.bootstrap(host, port); } get(key) { return this.#track(this.#native.get(key)); } put(key, data, options) { return this.#track(this.#native.put(key, data, options)); } + putSigned(key, data, options) { return this.#track(this.#native.putSigned(key, data, options)); } + putEncrypted(key, data, recipient, options) { + return this.#track(this.#native.putEncrypted(key, data, options, handle(recipient, PublicKey))); + } listen(key, callback) { const token = this.#native.listen(key, callback); @@ -68,4 +78,4 @@ class DhtRunner extends EventEmitter { [Symbol.asyncDispose]() { return this.close(); } } -module.exports = { DhtRunner, hash: native.hash, version: native.version }; \ No newline at end of file +module.exports = { DhtRunner, PublicKey, PrivateKey, Certificate, Identity, hash: native.hash, version: native.version }; \ No newline at end of file diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 7b6d9a27..f01eed65 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,11 +9,12 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "node-addon-api": "^8.5.0" + "node-gyp-build": "^4.8.4" }, "devDependencies": { "@types/node": "^22.0.0", "cmake-js": "^8.0.0", + "node-addon-api": "^8.5.0", "typescript": "^5.9.3" }, "engines": { @@ -299,6 +300,7 @@ "version": "8.9.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "dev": true, "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -311,6 +313,17 @@ "dev": true, "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index c8d61a24..617220da 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -6,20 +6,31 @@ "license": "MIT", "main": "index.js", "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "crypto.js", + "README.md", + "prebuilds/" + ], "engines": { "node": ">=22.9.0" }, "scripts": { "build": "cmake-js compile", + "bundle": "node scripts/bundle.js", + "package": "npm run bundle && npm pack --pack-destination dist", + "test:package": "node scripts/test-package.js", "test": "node --test test/*.test.js", "typecheck": "tsc --noEmit --strict --target ES2022 --module Node16 --moduleResolution Node16 test/types.ts" }, "dependencies": { - "node-addon-api": "^8.5.0" + "node-gyp-build": "^4.8.4" }, "devDependencies": { "@types/node": "^22.0.0", "cmake-js": "^8.0.0", + "node-addon-api": "^8.5.0", "typescript": "^5.9.3" } } \ No newline at end of file diff --git a/nodejs/scripts/bundle.js b/nodejs/scripts/bundle.js new file mode 100644 index 00000000..a1951784 --- /dev/null +++ b/nodejs/scripts/bundle.js @@ -0,0 +1,104 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const repository = path.dirname(root); +const input = path.resolve(process.argv[2] || path.join(root, 'build/Release/opendht.node')); +const target = `${process.platform}-${process.arch}`; +const output = path.join(root, 'prebuilds', target); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'opendht-bundle-')); +const run = (command, args) => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); + +function copyNotices(dependencies) { + const directory = path.join(output, 'licenses'); + fs.mkdirSync(directory, { recursive: true }); + fs.copyFileSync(path.join(repository, 'LICENSE'), path.join(directory, 'OpenDHT-LICENSE.txt')); + const copied = new Set(); + for (const dependency of dependencies) { + const real = fs.realpathSync(dependency); + if (!path.relative(repository, real).startsWith('..')) continue; + let name; + let notices; + if (process.platform === 'darwin') { + const match = real.match(/^(.*\/Cellar\/([^/]+)\/[^/]+)\//); + if (!match) throw new Error(`Cannot locate license notices for ${dependency}`); + name = match[2]; + notices = fs.readdirSync(match[1]).filter(file => /^(COPYING|LICENSE|COPYRIGHT)([._-]|$)/i.test(file)) + .map(file => path.join(match[1], file)); + } else if (process.platform === 'linux') { + let owner; + try { owner = run('dpkg-query', ['-S', real]); } + catch { owner = run('dpkg-query', ['-S', real.replace(/^\/usr\//, '/')]); } + name = owner.split('\n')[0].split(': ')[0].split(':')[0]; + notices = [path.join('/usr/share/doc', name, 'copyright')]; + } else { + const installed = process.env.VCPKG_INSTALLED_DIR; + const triplet = process.env.VCPKG_DEFAULT_TRIPLET || 'x64-windows-release'; + if (!installed) throw new Error('VCPKG_INSTALLED_DIR is required for Windows dependency notices'); + const share = path.join(installed, triplet, 'share'); + for (const entry of fs.readdirSync(share)) { + const copyright = path.join(share, entry, 'copyright'); + if (fs.existsSync(copyright)) fs.copyFileSync(copyright, path.join(directory, `${entry}-copyright.txt`)); + } + break; + } + if (!notices.length) throw new Error(`Missing license notices for ${name}`); + if (copied.has(name)) continue; + for (const notice of notices) fs.copyFileSync(notice, path.join(directory, `${name}-${path.basename(notice)}.txt`)); + copied.add(name); + } +} + +function fixMachO(file) { + const commands = run('otool', ['-l', file]); + const rpaths = [...commands.matchAll(/cmd LC_RPATH\s+cmdsize \d+\s+path (.+?) \(offset \d+\)/g)]; + for (const rpath of new Set(rpaths.map(match => match[1]))) run('install_name_tool', ['-delete_rpath', rpath, file]); + const dependencies = [...run('otool', ['-L', file]).matchAll(/^\s+(.+?) \(compatibility version/gm)]; + for (const [, dependency] of dependencies) { + if (!/^(@loader_path\/|\/usr\/lib\/|\/System\/Library\/)/.test(dependency)) { + throw new Error(`Non-relocatable dependency in ${path.basename(file)}: ${dependency}`); + } + } + run('codesign', ['--force', '--sign', '-', file]); +} + +try { + if (!['darwin', 'linux', 'win32'].includes(process.platform)) throw new Error(`Unsupported platform ${target}`); + if (!fs.existsSync(input)) throw new Error(`Build the Release addon first: ${input}`); + if (process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime) { + throw new Error('Linux prebuilds currently require glibc'); + } + const directories = [path.dirname(input), ...(process.env.OPENDHT_LIBRARY_PATH || '').split(path.delimiter).filter(Boolean)]; + const dependencyList = path.join(temporary, 'dependencies.json'); + run('cmake', [`-DADDON=${input}`, `-DOUTPUT=${dependencyList}`, `-DSEARCH_DIRECTORIES=${directories.join(';')}`, + '-P', path.join(__dirname, 'dependencies.cmake')]); + const dependencies = JSON.parse(fs.readFileSync(dependencyList, 'utf8')); + fs.rmSync(output, { recursive: true, force: true }); + fs.mkdirSync(output, { recursive: true }); + const binary = path.join(output, process.platform === 'linux' ? 'node.napi.glibc.node' : 'node.napi.node'); + fs.copyFileSync(input, binary); + if (process.platform === 'darwin') { + run('dylibbundler', ['-b', '-of', '-ns', '-x', binary, '-d', `${output}/`, '-p', '@loader_path/', '-s', path.dirname(input)]); + for (const file of fs.readdirSync(output)) fixMachO(path.join(output, file)); + } else { + for (const dependency of dependencies) fs.copyFileSync(dependency, path.join(output, path.basename(dependency))); + if (process.platform === 'linux') { + for (const file of fs.readdirSync(output)) run('patchelf', ['--set-rpath', '$ORIGIN', path.join(output, file)]); + } + } + copyNotices(dependencies); + fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify({ + platform: process.platform, + arch: process.arch, + napi: 8, + libraries: fs.readdirSync(output).filter(file => /\.(node|dll|dylib)$|\.so(\.|$)/.test(file)).sort(), + }, null, 2) + '\n'); + fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); + console.log(`Bundled ${target}: ${output}`); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} \ No newline at end of file diff --git a/nodejs/scripts/dependencies.cmake b/nodejs/scripts/dependencies.cmake new file mode 100644 index 00000000..69a1192a --- /dev/null +++ b/nodejs/scripts/dependencies.cmake @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.21) + +if(NOT EXISTS "${ADDON}" OR NOT DEFINED OUTPUT) + message(FATAL_ERROR "ADDON and OUTPUT are required") +endif() + +set(pre_exclude) +set(post_exclude) +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + list(APPEND pre_exclude "^/usr/lib/" "^/System/Library/") + list(APPEND post_exclude "^/usr/lib/" "^/System/Library/") +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + list(APPEND pre_exclude "^ld-linux" "^lib(c|m|dl|rt|pthread|resolv|util|gcc_s|stdc\\+\\+)\\.so") +elseif(CMAKE_HOST_WIN32) + list(APPEND pre_exclude "[Nn][Oo][Dd][Ee]\\.exe" "[Aa][Pp][Ii]-[Mm][Ss]-.*" "[Ee][Xx][Tt]-[Mm][Ss]-.*") + list(APPEND post_exclude ".*[Ss][Yy][Ss][Tt][Ee][Mm]32[/\\\\].*") +endif() + +file(GET_RUNTIME_DEPENDENCIES + MODULES "${ADDON}" + DIRECTORIES ${SEARCH_DIRECTORIES} + RESOLVED_DEPENDENCIES_VAR dependencies + UNRESOLVED_DEPENDENCIES_VAR unresolved + CONFLICTING_DEPENDENCIES_PREFIX conflicts + PRE_EXCLUDE_REGEXES ${pre_exclude} + POST_EXCLUDE_REGEXES ${post_exclude} +) +if(unresolved OR conflicts_FILENAMES) + message(FATAL_ERROR "Unresolved dependencies: ${unresolved}; conflicting dependencies: ${conflicts_FILENAMES}") +endif() + +set(result "[]") +set(index 0) +foreach(dependency IN LISTS dependencies) + file(TO_CMAKE_PATH "${dependency}" dependency) + string(JSON result SET "${result}" ${index} "\"${dependency}\"") + math(EXPR index "${index} + 1") +endforeach() +file(WRITE "${OUTPUT}" "${result}\n") \ No newline at end of file diff --git a/nodejs/scripts/test-package.js b/nodejs/scripts/test-package.js new file mode 100644 index 00000000..0061533b --- /dev/null +++ b/nodejs/scripts/test-package.js @@ -0,0 +1,39 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const npm = process.env.npm_execpath; +if (!npm) throw new Error('Run this check with npm run test:package'); +fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); +let archive = process.argv[2] ? path.resolve(process.argv[2]) : path.join(root, 'dist', + JSON.parse(execFileSync(process.execPath, [npm, 'pack', '--json', '--pack-destination', 'dist'], + { cwd: root, encoding: 'utf8' }))[0].filename); +if (fs.statSync(archive).isDirectory()) { + const archives = fs.readdirSync(archive).filter(file => file.endsWith('.tgz')); + if (archives.length !== 1) throw new Error('Expected exactly one npm archive'); + archive = path.join(archive, archives[0]); +} +const consumer = fs.mkdtempSync(path.join(os.tmpdir(), 'opendht-consumer-')); +const env = { ...process.env, PREBUILDS_ONLY: '1' }; +for (const variable of ['OPENDHT_NODE_BINDING', 'NODE_PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'DYLD_FALLBACK_LIBRARY_PATH']) delete env[variable]; +const excluded = [path.join(root, 'build'), ...(process.env.OPENDHT_LIBRARY_PATH || '').split(path.delimiter).filter(Boolean)]; +const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path'); +if (pathKey) env[pathKey] = env[pathKey].split(path.delimiter) + .filter(entry => !excluded.some(directory => path.resolve(entry).toLowerCase().startsWith(path.resolve(directory).toLowerCase()))) + .join(path.delimiter); +try { + execFileSync(process.execPath, [npm, 'install', '--prefix', consumer, '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', archive], + { cwd: consumer, env, stdio: 'inherit' }); + const installed = path.join(consumer, 'node_modules', '@opendht', 'node'); + execFileSync(process.execPath, ['-e', 'console.log(require("node-gyp-build").path(process.cwd()))'], + { cwd: installed, env, stdio: 'inherit' }); + fs.cpSync(path.join(root, 'test'), path.join(installed, 'test'), { recursive: true }); + execFileSync(process.execPath, [npm, 'test'], { cwd: installed, env, stdio: 'inherit' }); + console.log(`Installed archive verified: ${archive}`); +} finally { + fs.rmSync(consumer, { recursive: true, force: true }); +} \ No newline at end of file