
執筆者: こつ子
最終更新: 2026/02/28
分散型SNSに用いられるプロトコルNostr。このプロトコルでは自身の秘密鍵でイベントと呼ばれるJSONのデータに電子署名することで様々なデータの送信を行うことができます。この電子署名が本人確認となるため、この秘密鍵が漏洩する=死を表すのです。できるだけ安全に使いたいものですね。
今回の記事では分散型SNSなどを作れるプロトコルであるNostrにおいてアカウントともいえる秘密鍵をRaspberry Pi Picoの中に封じ込め、そこで電子署名することで外部に秘密鍵を漏らすことなくアカウントを運用できるようにします。
なお、Nostrについては手前味噌ですが、こちらの記事をご覧ください。
https://oucrc.net/articles/fye9lc6y59b9
なお、このプロジェクトのソースコードは以下のリポジトリに公開されています。合わせてご覧ください。
https://github.com/moyashi170607/nos_pico
では、開発環境について紹介します。
まず、今回使うマイコンは前述のとおり、Raspberry Pi Picoです。また、Pico側のコードはPicoSDKを用いてC++で記述していきます。Arduino + PlatformIOという構築もあったものの、電子署名の処理をするにあたり、できるだけPicoの能力を引き出せるようにするべきと考え、今回の構築にしました。
また、電子署名をするライブラリとしてはNostrの標準実装と同じく、bitcoinのコミュニティが作成しているライブラリ、secp256k1を用いることにしました。
また、ホスト側、つまりPicoを接続するPCで実行するプログラムにはRustを用います。これは特に深い意図はないです。NostrはTypeScriptで記述されたライブラリが多いため、TypeScript +Node.jsで構築するのが本来は自然なのかもしれないですが、前々から私がRustに興味があったことと、最近Rustが盛り上がっている機運を感じとったことから、今回はあえてRustを用いて記述することにしました。また、Rustのcrateに、Nostrのクライアント向けライブラリがあるものの、今回はそれをあえて採用していません。(採用してしまうと、もはやそれで署名から何から全てやればいいじゃんとなってしまうため)
代わりにRustの非同期処理を行うcrateである、tokioやtokioを用いてWebSocket通信を行うtokio-tungstenite 、同じくtokioを用いてシリアル通信をするtokio-serial を利用しました。
では、Pico側のコードを書いていきます。
まず、最初の壁となったのはPicoSDKの生成したCmakeファイルを編集してsecp256k1を一緒にビルドするように変更することです。makeファイルおよびCmakeファイルの記述が人類には早すぎるということは周知の事実であると思いますが、今回はとりあえずビルドに指定したライブラリをビルドしてもらえるようにすればひとまず及第点となります。その場合は、以下の部分を変更するだけでなんとかなります。(適当)
# 前略----------------------------------------
# Add the standard library to the build
target_link_libraries(nos_pico_hard
pico_stdlib
#picoで乱数を生成するために追加。電子署名では乱数が必要
pico_rand
#電子署名するためのライブラリ
secp256k1
)
# 略----------------------------------------
これにより、picoSDKが標準で持つライブラリであるpico_randとlibsファイル内に追加したsecp256k1がビルド対象に含まれるようになります。
まずはシリアル通信の部分について書いていきます。ここはシンプルに\rか\nが来るまで読み取り改行コードをはぎ取って解析するだけです。
while (true) {
// 1文字取得
int c = getchar_timeout_us(0);
if (c == PICO_ERROR_TIMEOUT) {
// 入力がない場合は待機
continue;
}
// 改行文字(CRまたはLF)を判定
if (c == '\r' || c == '\n') {
msg_buffer[buffer_index] = '\0'; // 終端文字を付与
if (buffer_index > 0) {
if (strncmp((char*)msg_buffer, "get_pubkey", 10) == 0) {
unsigned char pubkey[PUB_KEY_LENGTH];
get_public_key(pubkey);
// 結果の表示
printf("pubkey___:");
for (int i = 0; i < PUB_KEY_LENGTH; i++) {
printf("%02x", pubkey[i]);
}
printf("\n");
} else {
copy_trim_msg(msg_buffer, copy_buffer, buffer_index);
// unsigned char id_bin[BUFFER_MAX - 1];
sign(copy_buffer);
}
buffer_index = 0; // 次の入力のためにリセット
}
} else {
// バッファサイズ内で文字を格納
if (buffer_index < sizeof(msg_buffer) - 1) {
msg_buffer[buffer_index++] = (char)c;
}
}
}
このとき、受け取った文字列がget_pubkeyであるなら、公開鍵を返すようにしています。Nostrが発行するイベントJSONには公開鍵をプロパティの一つとして含める必要があるため、今回この機能を追加しました。
それ以外の文字列が来た時は、それは電子署名する対象となるハッシュ文字列なので署名する関数の方に渡します。
次に電子署名のコードを書く。secp256k1ライブラリが提供する関数に投げることになるのですが、その前にその引数に合うような形式に文字列を変形させる必要があります。
Nostrにイベントを投稿するには、投稿するイベントの要素を集めた配列をSHA-256でハッシュ化し、そうして得たハッシュ値を秘密鍵で署名する必要があります。本プロジェクトではハッシュ値を求める部分はホスト側で行い、署名はマイコンで行います。このホスト--マイコン間通信の際、ハッシュ値はマイコンに文字列として送られてきます。そのため、署名の前に文字列をバイナリに変換してあげる必要があります。以下が変換のコードです。文字を16進数として解釈した後、2文字で1byte分なので、シフト演算子を使って、詰めています。
// 16進数文字を数値に変換する補助関数
uint8_t hex_to_uint8(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return 0;
}
// 16進数文字列(64文字)をバイナリ(32バイト)に変換
void hex_to_bytes(unsigned const char* hex, uint8_t* bytes) {
for (int i = 0; i < 32; i++) {
bytes[i] =
(hex_to_uint8(hex[i * 2]) << 4) | hex_to_uint8(hex[i * 2 + 1]);
}
}これらの前処理ができたら、あとはライブラリに放り込みます。また、署名する際には、乱数が必要になります。(乱数を用いず同じ数字を再利用すると秘密鍵が漏洩する恐れがある)今回はrp2040備え付けの疑似乱数を用いましたが、ここはハードウェアであることを活かして、物理乱数などを取り入れたいですね。
int sign(unsigned char* msg_hex) {
unsigned char msg_hash[32];
hex_to_bytes(msg_hex, msg_hash);
// コンテキストの作成
secp256k1_context* ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN);
// 4. キーペアと公開鍵の作成
secp256k1_keypair keypair;
secp256k1_xonly_pubkey pubkey;
if (!secp256k1_keypair_create(ctx, &keypair, seckey)) return EXIT_FAILURE;
if (!secp256k1_keypair_xonly_pub(ctx, &pubkey, NULL, &keypair))
return EXIT_FAILURE;
// 5. 署名の実行 (Schnorr署名)
unsigned char signature[64];
// aux_rand32は補助的な乱数(NULLでも可ですが、サイドチャネル攻撃対策として推奨されます)
unsigned char aux_rand32[32] = {};
for (int i = 0; i < 32; i += 4) {
uint32_t r = get_rand_32();
memcpy(&aux_rand32[i], &r, 4);
}
if (!secp256k1_schnorrsig_sign32(ctx, signature, msg_hash, &keypair,aux_rand32)) {
return EXIT_FAILURE;
}
// 6. 結果の表示 (16進数)
for (int i = 0; i < 64; i++) printf("%02x", signature[i]);
printf("\n");
// リソースの解放
secp256k1_context_destroy(ctx);
return EXIT_SUCCESS;
} そして最後に、秘密鍵の入力です。これはconfig.hppとファイルを作成し、そこに定数として秘密鍵を設定しています。ここは小規模なファイルシステムなどを作成し、そこで保存できるようにしてもよさそう。
#ifndef CONFIG_NOSTR_KEY
#define CONFIG_NOSTR_KEY 1
#include <stdint.h>
const char* seckey_hex = "あなたの秘密鍵をhex形式で書く";
#endif // !CONFIG_NOSTR_KEY
以上がハードの主要なコードです。
次に、リレーサーバーと通信したり、イベントを作成したりするホスト側のコードをRustで書いていきます。
picoと通信するべく、tokio-serialでシリアル通信を開きます。ここでポイントなのがport.write_data_terminal_ready(true)?;という関数を実行すること。tokio-serialのサンプルコードには書かれていないが、これがないと上手くシリアル通信ができないみたいです。理由はすみません、私も良く分からないです。有識者はぜひご連絡ください。
このSerialManagerという構造体をインスタンス化することでシリアル通信を開くことができます。シリアル通信のパーサー自体はシンプルなもので、改行コードが来るまで受け取り、改行コードまでを切り取って文字列化して返すだけです。
use tokio::io::{self, AsyncWriteExt};
use tokio_serial::{SerialPort, SerialPortBuilderExt, SerialStream};
use tokio_util::{bytes::BytesMut, codec::Decoder};
pub struct LineCodec;
//シリアル通信を読む
impl Decoder for LineCodec {
type Item = String;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
//改行コードを探す
let newline = src.as_ref().iter().position(|b| *b == b'\n');
//改行コードまでを切り取る
if let Some(n) = newline {
let line = src.split_to(n + 1);
return match str::from_utf8(line.as_ref()) {
//文字列化
Ok(s) => Ok(Some(s.to_string())),
//エラー
Err(_) => Err(io::Error::new(io::ErrorKind::Other, "Invalid String")),
};
}
//まだ改行コードまで来てないので待機
Ok(None)
}
}
//シリアル通信の管理
pub struct SerialManager {
//port: SerialStream,
pub reader: tokio_util::codec::Framed<SerialStream, LineCodec>,
}
impl SerialManager {
///指定したポートとシリアル通信を開く
pub async fn open_port(
port_name: &str,
baud_rate: u32,
) -> Result<Self, Box<dyn std::error::Error>> {
// ? を使って Result から SerialStream を取り出す
let mut port: SerialStream = tokio_serial::new(port_name, baud_rate).open_native_async()?;
//良く分からないけど必要らしいね
port.write_data_terminal_ready(true)?;
//ポートを利用しやすい形に変
let reader: tokio_util::codec::Framed<SerialStream, LineCodec> = LineCodec.framed(port);
// 取り出した port を構造体にセットして返す
Ok(SerialManager { reader })
}
///データの送信
pub async fn send_data(&mut self, data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
self.reader.get_mut().write_all(data).await?; // 送信失敗ならここで中断して戻る
self.reader.get_mut().write_all(b"\n").await?; // 改行コード(\n)を送信
Ok(()) // 成功なら「空の値 ()」を Ok で包んで返す
}
}let serial_manager = match SerialManager::open_port(&port_name, BAUD_RATE).await {
Ok(port) => {
println!("デバイスとの接続を確認");
port
}
Err(err) => {
eprintln!("ポートのオープンに失敗しました: {}", err);
return;
}
}; マイコンとの接続が確保できたので、次はNostrのリレーサーバと接続しましょう。次はtokio-tungsteniteの出番ですね。NostrはリレーサーバーとWebSocketで接続する必要があるので接続していきます。また、接続後は書き込み用と読み込み用にストリームを分け、配列に保存しています。こうして設定ファイルに書かれたリレーサーバーと接続できましたね。
use futures::{
StreamExt,
stream::{SplitSink, SplitStream},
};
use tokio::net::TcpStream;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message};
///WebSocketを開く
pub async fn open_ws(
relays: Vec<String>,
) -> (
Vec<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>,
Vec<SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>>,
) {
let mut ws_stream_vec: Vec<WebSocketStream<MaybeTlsStream<TcpStream>>> = vec![];
// WebSocket接続を開始
for i in relays {
let (ws_stream, _) = connect_async(i).await.expect("Failed to connect");
ws_stream_vec.push(ws_stream);
}
println!("Connect");
let mut ws_writers: Vec<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>> =
vec![];
let mut ws_readers: Vec<SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>> = vec![];
//書き込みと読み込みに分ける
for i in ws_stream_vec {
let (writer, reader) = i.split();
ws_writers.push(writer);
ws_readers.push(reader);
}
//タプル型で返す
(ws_writers, ws_readers)
} println!("リレーサーバーに接続中...");
//リレーサーバとの通信を開始
let (ws_writers, _ws_readers) = nostr::web_socket::open_ws(relays).await;しかしこのままではちと使いにくいです。複数のリレーと接続し、それらに一斉に送信することになるのですが、forで回すのは事故ることもある。非同期処理なので所有権周りも事故りがちですね。
ということでユーザーとWebSocket書き込みストリーム群の間に送信機txと受信機rxを作ります。こうすることで、ユーザーはtx側に送りたい情報を入れるだけでrx側からいい感じにデータを非同期で取り出し、ストリームで送り出してくれるというわけですね。役割分担というのは大事です。
use futures::stream::SplitSink;
use tokio::{
net::TcpStream,
sync::mpsc::{self, Sender},
};
use tokio_tungstenite::{
MaybeTlsStream, WebSocketStream,
tungstenite::{self, Message},
};
use futures::SinkExt;
pub fn start_ws_send_tx(
ws_writers: Vec<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>,
) -> Sender<String> {
//送受信機を作成
//txにデータを入れることで送信できる。rxはtxに入れらえたデータを取り出す
let (ws_send_tx, mut ws_send_rx) = mpsc::channel::<String>(100);
//txに入ったデータをリレーサーバーに送信
//tokioによって非同期で別タスクとして処理される
tokio::spawn(async move {
let mut writers = ws_writers;
while let Some(json_msg) = ws_send_rx.recv().await {
// format! で作った String を用意
let req_str = format!("[\"EVENT\",{}]", json_msg);
println!("{}", req_str);
// String を Utf8Bytes に変換して Message::Text を作成
let msg = tungstenite::protocol::Message::Text(req_str.into()); // .into() で変換可能
//順番にリレーサーバーに送信
for writer in writers.iter_mut() {
let _ = writer.send(msg.clone()).await;
println!("リレー送信");
}
}
});
ws_send_tx
}
//WebSocket送信のtx rxを作成
let ws_send_tx = start_ws_send_tx(ws_writers);
let mut app = NostrApp::new(serial_manager, ws_send_tx);同じ感じで入力にもtxとrxを作って非同期で受け取れるようにします。こうして受け取った
// 標準入力をブロックせずに受け取るためのヘルパー関数
fn input_blocking() -> String {
io::stdout().flush().unwrap();
let mut buffer = String::new();
io::stdin().read_line(&mut buffer).expect("読み込み失敗");
buffer.trim().to_owned()
}
#[tokio::main]
async fn main() {
---中略----------------------------------------------------
let (input_tx, mut input_rx) = mpsc::channel::<String>(32);
tokio::spawn(async move {
loop {
let msg = tokio::task::spawn_blocking(input_blocking).await.unwrap();
if !msg.is_empty() {
input_tx.send(msg).await.ok();
}
}
});
---------------------------------------------------------------
} Nostrのイベントを作ったり、電子署名が正しいか検証したりするためには公開鍵が必要です。秘密鍵から求めることができるのですが、秘密鍵を持っているのはpico側ですね。picoでget_pubkeyというシリアル通信を受け取ったとき公開鍵を返すようにしているため、get_pubkeyを送りましょう。
//公開鍵の送信をマイコンにリクエスト
println!("公開鍵を取得中...");
let _ = SerialManager::send_data(&mut app.serial, "get_pubkey".as_bytes()).await;Nostrはイベントと呼ばれる形式で送信するデータを作り、それに対して電子署名してリレーサーバーに送る。というわけでイベントを作成する機能を作る。
今回対応するイベントは通常投稿、つまりkind1である。イベントには以下の要素が必要である。
まずは、上記のうちsig以外の要素を集めましょう。とはいっても公開鍵は前述のget_pubkeyで入手したもの、Unixタイムスタンプはchronoとか使って取得、kindは1で固定、タグは未対応なので空の配列、メッセージ本文は標準入力から受け取ったものをそのまま入れるだけですけどね。
その後、idを算出するために、[0, 公開鍵, タイムスタンプ, kind, tags, content]という配列を作り、それを文字列化、さらにsha-256でハッシュ化します。このハッシュ値がそのイベントのidになるわけですね。
use hex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{self, Digest, Sha256};
use crate::nostr::NostrSignedEvent;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct NostrUnsignEvent {
pub id: String,
pub pubkey: String, // 送信者の公開鍵
pub created_at: i64, // Unixタイムスタンプ
pub kind: u32, // イベントの種類(0: Metadata, 1: Short Text Note等)
pub tags: Vec<Vec<String>>, // タグの配列
pub content: String, // メッセージ本体
}
///署名前のKind1イベントを生成
pub fn generate_kind1_event(content: &str, pubkey: &str, timestamp: &i64) -> NostrUnsignEvent {
let tags: Vec<Vec<String>> = vec![];
let id_source_json = json!([0, &pubkey, ×tamp, 1, &tags, &content]);
let serialized_data = serde_json::to_string(&id_source_json).unwrap();
let mut hasher = Sha256::new();
hasher.update(serialized_data.as_bytes());
let result = hasher.finalize();
let id: String = hex::encode(result);
NostrUnsignEvent {
id: id,
pubkey: pubkey.to_string(),
created_at: *timestamp,
kind: 1,
tags,
content: content.to_string(),
}
}
その後は、シリアル通信でマイコン側に送信、マイコンから電子署名した結果が返ってくるのを待ちます。
async fn main() {
//--------中略-----------------------------------
loop {
tokio::select! {
// シリアルポートからデータ(署名や公開鍵)が届いたとき
Some(line_result) = app.serial.reader.next() => {
match line_result {
Ok(line) => {
app.handle_serial_line(line).await;
}
Err(e) => eprintln!("シリアル読込エラー: {}", e),
}
}
// ユーザーがコンソールにメッセージを打ち込んだとき
Some(user_msg) = input_rx.recv() => {
app.handle_user_input(user_msg).await;
}
}
}
}受け取った署名は正しいかどうか検証されたのちに、先ほどのイベントのsigの部分として追加されます。こうしてできたイベントはWebSocketの送信キューに送られます。
pub async fn handle_serial_line(&mut self, line: String) {
let trimmed_line = line.trim();
if trimmed_line.starts_with("pubkey___:") {
let raw_pk = trimmed_line.replace("pubkey___:", "");
// 公開鍵は必ず64文字(32バイトのHex)
if raw_pk.len() >= 64 {
self.pubkey = raw_pk[..64].to_string();
println!("公開鍵を取得: {}", self.pubkey);
}
} else if let Some(event) = self.pending_event.take() {
println!("署名を受信");
if trimmed_line.len() >= 128 {
if verify::verify_nostr_signature(
&event.id, // 送信したID
&self.pubkey, // 自分の公開鍵
&trimmed_line, // 返ってきた署名
) {
let signed_event = generate_signed_event(&event, &trimmed_line);
let event_json = match serde_json::to_string(&signed_event) {
Ok(event) => event,
Err(err) => {
println!("JSON化に失敗{}", err);
return;
}
};
//WebSocket送信キューに追加
if let Err(err) = self.ws_tx.send(event_json).await {
println!("WebSocket送信キューへの追加に失敗:{}", err);
} else {
println!("イベントをリレーサーバーに送信");
}
} else {
println!("ERROR:署名の検証に失敗しました");
println!("{}", serde_json::to_string(&event).unwrap());
}
}
}
} 先ほどキューに入れられたイベントは接続されたリレーサーバーに送信されます。この際、リレーサーバーには["EVENT", <イベントのJSON]`という形で送信されます。これがリレーサーバーで受理されると、無事Nostrに投稿できたというわけです。
pub fn start_ws_send_tx(
ws_writers: Vec<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>,
) -> Sender<String> {
//送受信機を作成
//txにデータを入れることで送信できる。rxはtxに入れらえたデータを取り出す
let (ws_send_tx, mut ws_send_rx) = mpsc::channel::<String>(100);
//txに入ったデータをリレーサーバーに送信
//tokioによって非同期で別タスクとして処理される
tokio::spawn(async move {
let mut writers = ws_writers;
while let Some(json_msg) = ws_send_rx.recv().await {
// format! で作った String を用意
let req_str = format!("[\"EVENT\",{}]", json_msg);
println!("{}", req_str);
// String を Utf8Bytes に変換して Message::Text を作成
let msg = tungstenite::protocol::Message::Text(req_str.into()); // .into() で変換可能
//順番にリレーサーバーに送信
for writer in writers.iter_mut() {
// ここで複製が必要な場合は msg.clone()
let _ = writer.send(msg.clone()).await;
println!("リレー送信");
}
}
});
ws_send_tx
}長々と説明しましたが、以上がRust側のコードになります。
大変お待たせしました。本プロジェクトの実演となります。
https://github.com/moyashi170607/nos_pico/blob/main/README.md
上記のリポジトリのREAD.mdに従って、設定等を済ませ、マイコンにバイナリを書き込み、実行ファイルを実行してみます。


無事投稿できましたね!これでPCのメモリに秘密鍵を展開することなく電子署名ができました。PCを変えた場合も秘密鍵を頑張ってコピーする必要もありませんね!
いかがでしたか?
今回はRaspberry Pi Pico内で電子署名し、それをNostrのリレーサーバーに投稿するアプリを作成しました。皆さんもPicoとNostrで分散型SNSを楽しみましょう!