Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ContactInfoContent signing + config toggle for strict validation + StrictNoSign #1545

Merged
merged 11 commits into from
Oct 26, 2023
7 changes: 7 additions & 0 deletions .changeset/new-fireants-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@farcaster/hub-web": minor
"@farcaster/core": minor
"@farcaster/hubble": minor
---

Adds support for contact info content signing + strictNoSign
24 changes: 23 additions & 1 deletion apps/hubble/src/addon/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
use std::convert::TryInto;

use ed25519_dalek::{Signature, VerifyingKey};
use ed25519_dalek::{Signature, Signer, SigningKey, EXPANDED_SECRET_KEY_LENGTH, VerifyingKey};
use neon::{prelude::*, types::buffer::TypedArray};

fn ed25519_sign_message_hash(mut cx: FunctionContext) -> JsResult<JsBuffer> {
CassOnMars marked this conversation as resolved.
Show resolved Hide resolved
let hash_arg = cx.argument::<JsBuffer>(0)?;
let signing_key_arg = cx.argument::<JsBuffer>(1)?;

let signing_key_bytes: [u8; EXPANDED_SECRET_KEY_LENGTH] = match signing_key_arg.as_slice(&cx).try_into() {
Ok(bytes) => bytes,
Err(_) => return cx.throw_error("could not decode signing key"),
};

let signer = match SigningKey::from_keypair_bytes(&signing_key_bytes) {
Ok(signer) => signer,
Err(_) => return cx.throw_error("could not construct signing key"),
};

let signature = signer.sign(&hash_arg.as_slice(&cx)).to_bytes();
let mut buffer = cx.buffer(signature.len())?;
let target = buffer.as_mut_slice(&mut cx);
target.copy_from_slice(&signature);
Ok(buffer)
}

fn ed25519_verify(mut cx: FunctionContext) -> JsResult<JsNumber> {
let signature_arg = cx.argument::<JsBuffer>(0)?;
let hash_arg = cx.argument::<JsBuffer>(1)?;
Expand Down Expand Up @@ -47,6 +68,7 @@ fn blake3_20(mut cx: FunctionContext) -> JsResult<JsBuffer> {

#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
cx.export_function("ed25519_signMessageHash", ed25519_sign_message_hash)?;
cx.export_function("ed25519_verify", ed25519_verify)?;
cx.export_function("blake3_20", blake3_20)?;
Ok(())
Expand Down
131 changes: 118 additions & 13 deletions apps/hubble/src/hubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ import {
OnChainEvent,
onChainEventTypeToJSON,
ClientOptions,
validations,
HashScheme,
} from "@farcaster/hub-nodejs";
import { PeerId } from "@libp2p/interface-peer-id";
import { peerIdFromBytes, peerIdFromString } from "@libp2p/peer-id";
import { publicAddressesFirst } from "@libp2p/utils/address-sort";
import { unmarshalPrivateKey, unmarshalPublicKey } from "@libp2p/crypto/keys";
import { Multiaddr, multiaddr } from "@multiformats/multiaddr";
import { Result, ResultAsync, err, ok } from "neverthrow";
import { GossipNode, MAX_MESSAGE_QUEUE_SIZE, GOSSIP_SEEN_TTL } from "./network/p2p/gossipNode.js";
Expand All @@ -34,6 +37,7 @@ import Engine from "./storage/engine/index.js";
import { PruneEventsJobScheduler } from "./storage/jobs/pruneEventsJob.js";
import { PruneMessagesJobScheduler } from "./storage/jobs/pruneMessagesJob.js";
import { sleep } from "./utils/crypto.js";
import { nativeValidationMethods } from "./rustfunctions.js";
import * as tar from "tar";
import * as zlib from "zlib";
import { logger, messageToLog, messageTypeToName, onChainEventToLog, usernameProofToLog } from "./utils/logger.js";
Expand Down Expand Up @@ -264,6 +268,12 @@ export interface HubOptions {

/** If set, overrides the default application-specific score cap */
applicationScoreCap?: number;

/** If set, requires contact info messages to be signed */
strictContactInfoValidation?: boolean;

/** If set, requires gossip messages to utilize StrictNoSign */
strictNoSign?: boolean;
}

/** @returns A randomized string of the format `rocksdb.tmp.*` used for the DB Name */
Expand All @@ -288,6 +298,8 @@ export class Hub implements HubInterface {
private allowedPeerIds: string[] | undefined;
private deniedPeerIds: string[];
private allowlistedImmunePeers: string[] | undefined;
private strictContactInfoValidation: boolean;
private strictNoSign: boolean;

private s3_snapshot_bucket: string;

Expand Down Expand Up @@ -382,6 +394,8 @@ export class Hub implements HubInterface {
this.fNameRegistryEventsProvider,
profileSync,
);
this.strictContactInfoValidation = options.strictContactInfoValidation || false;
this.strictNoSign = options.strictNoSign || false;

// On syncComplete, we update the denied peer ids list with the bad peers.
// This is not active yet.
Expand Down Expand Up @@ -660,6 +674,7 @@ export class Hub implements HubInterface {
directPeers: this.options.directPeers,
allowlistedImmunePeers: this.options.allowlistedImmunePeers,
applicationScoreCap: this.options.applicationScoreCap,
strictNoSign: this.options.strictNoSign,
});

await this.registerEventHandlers();
Expand Down Expand Up @@ -692,12 +707,21 @@ export class Hub implements HubInterface {

/** Apply the new the network config. Will return true if the Hub should exit */
public applyNetworkConfig(networkConfig: NetworkConfig): boolean {
const { allowedPeerIds, deniedPeerIds, allowlistedImmunePeers, shouldExit } = applyNetworkConfig(
const {
allowedPeerIds,
deniedPeerIds,
allowlistedImmunePeers,
strictContactInfoValidation,
strictNoSign,
shouldExit,
} = applyNetworkConfig(
networkConfig,
this.allowedPeerIds,
this.deniedPeerIds,
this.options.network,
this.options.allowlistedImmunePeers,
this.options.strictContactInfoValidation,
this.options.strictNoSign,
);

if (shouldExit) {
Expand All @@ -710,6 +734,8 @@ export class Hub implements HubInterface {
this.deniedPeerIds = deniedPeerIds;

this.allowlistedImmunePeers = allowlistedImmunePeers;
this.strictContactInfoValidation = !!strictContactInfoValidation;
this.strictNoSign = !!strictNoSign;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd need to restart the GossipNode node if this changed right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, will follow up


log.info({ allowedPeerIds, deniedPeerIds, allowlistedImmunePeers }, "Network config applied");

Expand Down Expand Up @@ -827,18 +853,41 @@ export class Hub implements HubInterface {
});

const snapshot = await this.syncEngine.getSnapshot();
return snapshot.map((snapshot) => {
return ContactInfoContent.create({
gossipAddress: gossipAddressContactInfo,
rpcAddress: rpcAddressContactInfo,
excludedHashes: [], // Hubs don't rely on this anymore,
count: snapshot.numMessages,
hubVersion: FARCASTER_VERSION,
network: this.options.network,
appVersion: APP_VERSION,
timestamp: Date.now(),
});
if (snapshot.isErr()) {
return err(snapshot.error);
}

const content = ContactInfoContent.create({
gossipAddress: gossipAddressContactInfo,
rpcAddress: rpcAddressContactInfo,
excludedHashes: [], // Hubs don't rely on this anymore,
count: snapshot.value.numMessages,
hubVersion: FARCASTER_VERSION,
network: this.options.network,
appVersion: APP_VERSION,
timestamp: Date.now(),
});
const peerId = this.gossipNode.peerId();
const privKey = peerId?.privateKey;
if (privKey) {
const rawPrivKey = await unmarshalPrivateKey(privKey);
const hash = await validations.createMessageHash(
ContactInfoContent.encode(content).finish(),
HashScheme.BLAKE3,
nativeValidationMethods,
);
if (hash.isErr()) {
return err(hash.error);
}
const signature = await validations.signMessageHash(hash.value, rawPrivKey.marshal(), nativeValidationMethods);
if (signature.isErr()) {
return err(signature.error);
}

content.signature = signature.value;
content.signer = rawPrivKey.public.marshal();
}
return ok(content);
}

async teardown() {
Expand Down Expand Up @@ -979,14 +1028,70 @@ export class Hub implements HubInterface {
}
}

private async handleContactInfo(peerId: PeerId, message: ContactInfoContent): Promise<boolean> {
private async handleContactInfo(peerId: PeerId, content: ContactInfoContent): Promise<boolean> {
statsd().gauge("peer_store.count", await this.gossipNode.peerStoreCount());

let message = ContactInfoContent.create({
gossipAddress: content.gossipAddressContactInfo,
rpcAddress: content.rpcAddressContactInfo,
excludedHashes: content.excludedHashes,
count: content.count,
hubVersion: content.hubVersion,
network: content.network,
appVersion: content.appVersion,
timestamp: content.timestamp,
dataBytes: content.dataBytes,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContactInfoContent.decode(ContactInfoContent.encode(content).finish()) is more future proofs for when new fields get added. Maybe also worth pulling the dataBytes check out here for clarity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we don't have a envelope to this, we have to explicitly extract the values that would exist pre-signed, but agreed we need a way to future proof this better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could introduce a body field just like Message and duplicate the fields on there since we're changing it anyway. We can deprecate the outer fields in 1.8

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, happy to proceed with this

if (content.signature && content.signer && peerId.publicKey) {
let bytes: Uint8Array;
if (message.dataBytes) {
bytes = message.dataBytes;
} else {
bytes = ContactInfoContent.encode(message).finish();
}

const pubKey = unmarshalPublicKey(peerId.publicKey);
if (Buffer.compare(pubKey.marshal(), content.signer) !== 0) {
log.debug({ message: content }, "signer mismatch for contact info");
CassOnMars marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

const hash = await validations.createMessageHash(bytes, HashScheme.BLAKE3, nativeValidationMethods);
if (hash.isErr()) {
log.debug({ message: content }, "could not hash message");
return false;
}

const result = await validations.verifySignedMessageHash(
hash.value,
content.signature,
content.signer,
nativeValidationMethods,
);
if (result.isErr()) {
log.debug({ message: content, error: result.error }, "signature verification failed for contact info");
return false;
}

if (!result.value) {
log.debug({ message: content }, "signature verification failed for contact info");
CassOnMars marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

if (message.dataBytes) {
message = ContactInfoContent.decode(message.dataBytes);
}
} else if (this.strictContactInfoValidation) {
log.debug({ message, peerId }, "provided contact info does not have a signature");
return false;
}

// Don't process messages that are too old
if (message.timestamp && message.timestamp < Date.now() - MAX_CONTACT_INFO_AGE_MS) {
log.debug({ message }, "contact info message is too old");
return false;
}

// Updates the address book for this peer
const gossipAddress = message.gossipAddress;
if (gossipAddress) {
Expand Down
2 changes: 2 additions & 0 deletions apps/hubble/src/network/p2p/gossipNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface NodeOptions {
allowlistedImmunePeers?: string[] | undefined;
/** Override application score cap. */
applicationScoreCap?: number | undefined;
/** Determines whether messages are required to be strictly unsigned */
strictNoSign?: boolean | undefined;
}

// A common return type for several methods on the libp2p node.
Expand Down
2 changes: 1 addition & 1 deletion apps/hubble/src/network/p2p/gossipNodeWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class LibP2PNode {
emitSelf: false,
allowPublishToZeroPeers: true,
asyncValidation: true, // Do not forward messages until we've merged it (prevents forwarding known bad messages)
globalSignaturePolicy: "StrictSign",
globalSignaturePolicy: options.strictNoSign ? "StrictNoSign" : "StrictSign",
msgIdFn: this.getMessageId.bind(this),
directPeers: options.directPeers || [],
canRelayMessage: true,
Expand Down
Loading
Loading