🔐

JavascriptとGolang間でECDHE鍵交換とAES-GCM暗号化をやってみた話

2023/12/10に公開

1 はじめに

1.1 きっかけ

私は、今まで技術ブログというものを書いたことがありませんでした。しかし、「みすてむず」というSNS(Misskey)にて2023年アドカレの企画が立ち上がったので、これをきっかけとしてZennで技術ブログを始めてみることにしました。

1.2 この記事について

https(TLS over http)では、BrowserとServerの間で暗号通信を行っていることが知られています。ではどのような方式で暗号通信が行われているのでしょうか。

例えば、amazonのホームページをEdgeで開いてみて、「ctrl+shift+I」を押すと開発者ツールが開きます。そしてセキュリティのタブを見てみると、下記のように書かれています。

これは、TLSのバージョンは1.3で、そこで用いられている暗号通信方式として、まずX25519と呼ばれる楕円曲線を用いたECDHE方式で鍵交換が行われて、つぎに、その共通鍵を用いてAES-GCMと呼ばれる共通鍵方式により暗号化が行われていることを表しています。128は共通鍵の長さ(単位はビット)に相当します。

本記事は、Javascript(Vue)とgolangを用いて、httpのまま、このECDHE鍵交換とAES-GCM暗号化によってデータを暗号化して送受信するwebアプリを作成することを目指します。最初の開発環境構築とデバッグの立ち上げ方も含めて、私自身が見直すための備忘録も兼ねています。

2 環境

OS

  • Windows 10

IDE

  • VScode

使用言語

  • Javascript (Vue Composition API)
  • Golang

その他

  • Redis Server

Browser側ではAES-GCMは@noble/cipherというライブラリを使い、pbkdf2とsha256はhash-wasmというライブラリを使いました。

https://github.com/paulmillr/noble-ciphers
https://github.com/Daninet/hash-wasm

なおServer側ではgoogleが提供しているcrypto/cipherライブラリを使いました。
https://pkg.go.dev/crypto/cipher

3 ECDHE鍵交換+AES-GCM暗号化の流れ

3.1 ECDHE鍵交換Phase

X25519によるECDHE鍵交換により、通信経路上には共通鍵の情報を流すことなく、BrowserとServerの間で共通鍵を作成することができます。

3.2 AES-GCM暗号化Phase(Browser → Server)

上記で交換した共通鍵を用いて、AES-GCMによりBrowserでDataを暗号化し、Serverで復号します。

3.3 AES-GCM暗号化Phase(Browser ← Server)

同様にAES-GCMによりServerでDataを暗号化し、Browserで復号します。

4 フロントエンドの実装

4.1 フロントエンドの準備

まずターミナルで、ブラウザ側の新規プロジェクトのフォルダを作りたいフォルダに移動して、プロジェクトを作ります。

npm create vue

と打つと、順次選択肢が現れるので、下記の通り選択します。

? Project name: >> misstemsadv2023frontend
? Add TypeScript? » No
? Add JSX Support? » No 
? Add Vue Router for Single Page Application development? » Yes
? Add Pinia for state management? » Yes
? Add Vitest for Unit Testing? » No
? Add an End-to-End Testing Solution? » - Use arrow-keys. Return to submit.
>   No
? Add ESLint for code quality? » Yes
? Add Prettier for code formatting? » Yes

次に、フォーマットして、開発サーバーが立ち上がることを確認します。

cd misstemsadv2023frontend
npm install
npm run format
npm run dev

以下の表示になったら、立ち上がっているサーバーのポート番号をメモして、ctrl+cでサーバーを止めます。今回は5173でした。

  VITE v4.5.0  ready in 265 ms
  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h to show help

プロジェクトのフォルダをVScodeで開きます。
トップの項目が今回作成したプロジェクトフォルダ名になっていることを確認します。

4.2 デバッガーの準備

App.vueを選択して、
上のメニューから実行→構成の追加を選択します。

と出たら、Webアプリ(Edge)とWebアプリ(Chrome)好みで選択してください。
今回はEdgeで進めるとします。

.vscodeフォルダの下にlaunch.jsonができますので、localhostのポート番号を先ほどメモした5173に変更します。

.vscode/launch.json
{
    // IntelliSense を使用して利用可能な属性を学べます。
    // 既存の属性の説明をホバーして表示します。
    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "msedge",
            "request": "launch",
            "name": "localhost に対して Edge を起動する",
+            "url": "http://localhost:5173",
-            "url": "http://localhost:8080",
            "webRoot": "${workspaceFolder}"
        }
    ]
}

4.3 ファイル構成の整理

ファイル構成を最低限の構成にします。
*削除 /view/AboutView.vue
*削除 /components/*.vue
*削除 /components/icon/*.vue
*名前変更 /stores/counter.js -> /stores/store.js

ファイルの中身を最低限の構成に書き換えます。

<RouterView />のところに後述するrouter/index.jsで指定したHomeViewが読み込まれます。

App.vue
<script setup>
import { RouterView } from 'vue-router'
</script>

<template>
  <RouterView />
</template>

<style scoped></style>

後述する、倉庫の役割となるstore/store.jsをインポートします。

view/HomeView.vue
<script setup>
import { useStore } from "@/stores/store"
const store = useStore()
</script>

<template>
  <main>
    test
  </main>
</template>

ルーティングはとりあえず"/"にHomeViewだけ割り当てています。
つまりlocalhost:5173/と入れると上述した<RouterView />にHomeViewが読みこまれます。

router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
  ]
})

export default router

store.jsはpiniaを使って、変数や関数を共通化して保存できるようにする倉庫の役割です。

store/store.js
import { defineStore } from 'pinia'

export const useStore = defineStore(
  {
    id: 'auth',
    status:{
    },
    actions:{
    }
  }
)

最初にnpm create vueしたときに自動で生成していますが、参考までにmain.jsの中身を記します。

main.js
import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

ここまで書き換えたら、npm run devして開発サーバーを建てて、
メニューから実行→デバッグの開始 を実行するとブラウザが立ち上がって
「test」と表示されることを確認します。
ホットリロードがかかるので、サーバーを停止する必要はありません。

4.4 鍵ペアの作成

下記ライブラリをnpm i curve25519でインストールします。
https://github.com/harveyconnor/curve25519-js

まずクライアントの秘密鍵と公開鍵を作る必要があります。
generateKeyPairを参照すると、長さ32Uint8Array型のseedが必要なようです。

generateKeyPair(seed: Uint8Array(32)): { 
  private: Uint8Array(32);
  public: Uint8Array(32);
}

楕円曲線のX25519でClient秘密鍵と公開鍵のペアが作れることを確認します。
Uint8ArrayのままではJSONで送信しづらいので、Base64stringにエンコードするようにします。

なお、Uint8Array⇔Base64Stringの変換は下記を参考にしました。
https://scrapbox.io/nwtgck/JavaScriptでUint8Array_⇄_Base64文字列の相互変換

store.jsを書き換えます。

store/store.js
import { defineStore } from 'pinia'
import { generateKeyPair } from 'curve25519-js';

export const useStore = defineStore(
  {
    id: 'auth',
    status:{
      clientPrivateKeyBase64: null,
      clientPublicKeyBase64 : null,
    },
    actions:{
      async tradekey(){
        const typedArray = new Uint8Array(32) //作りたい乱数の型宣言 generateKeyPairのseedとしてUint8Array(32)が指定されている
        const cryptoArray = crypto.getRandomValues(typedArray)  //指定した型で暗号強度の乱数を作る
        const clientKeyPair = generateKeyPair(cryptoArray)    //Curve25519でClient秘密鍵と公開鍵のペアを作る
        const clientPrivateKey = clientKeyPair.private  //Uint8Array(32)のClient秘密鍵
        const clientPublicKey = clientKeyPair.public  //Uint8Array(32)のClient公開鍵
        this.clientPrivateKeyBase64 = this.uint8ArrayToBase64(clientPrivateKey)   //ブラウザで確認するため、Base64エンコード
        this.clientPublicKeyBase64 = this.uint8ArrayToBase64(clientPublicKey)   //JSON形式でサーバーに送信するため、Base64エンコード
        console.log(clientPrivateKey)
        console.log(clientPublicKey)
      },
      uint8ArrayToBase64(uint8Array){
        return btoa(String.fromCharCode(...uint8Array))
      },
    }
  }
)

HomeViewを下記の通り書き換えます。

views/HomeView.vue
<script setup>
import { reactive } from "vue"
import { useStore } from "@/stores/store"
const store = useStore()
const data = reactive({
  clientPrivateKey: '',
  clientPublicKey: '',
})

const make = async () => {
  await store.tradekey()
  data.clientPrivateKey = store.clientPrivateKeyBase64
  data.clientPublicKey = store.clientPublicKeyBase64
}

</script>

<template>
  <main>
    <div>
      <span>ClientPrivateKey</span><br />
      <input v-model="data.clientPrivateKey" class="input"><br />
      <span>ClientPublicKey</span><br />
      <input v-model="data.clientPublicKey" class="input"><br />
    </div><br />
    <div>
      <button @click="make">Make ClientKeyPair</button>
    </div>
  </main>
</template>

<style scoped>
.input {
  width: 400px;
}
</style>

ボタンを押すと、consoleにUint8ArrayのClient秘密鍵と公開鍵のペアが、inputにBase64エンコードされたClient秘密鍵と公開鍵のペアが表示されることを確認しました。

4.5 Client公開鍵の送信準備

次にこのClient公開鍵をJSON形式で送信したいと思います。
まずaxiosをインストールします。

npm install axios

そしてstore.jsに下記追記します。

store/store.js
import { defineStore } from 'pinia'
import { generateKeyPair } from 'curve25519-js';
+import axios from "axios"

export const useStore = defineStore(
  {
    id: 'auth',
    status:{
      clientPrivateKeyBase64: null,
      clientPublicKeyBase64 : null,
    },
    actions:{
      async tradekey(){
        const typedArray = new Uint8Array(32) //作りたい乱数の型宣言 generateKeyPairのseedとしてUint8Array(32)が指定されている
        const cryptoArray = crypto.getRandomValues(typedArray)  //指定した型で暗号強度の乱数を作る
        const clientKeyPair = generateKeyPair(cryptoArray)    //Curve25519でClient秘密鍵と公開鍵のペアを作る
        const clientPrivateKey = clientKeyPair.private  //Uint8Array(32)のClient秘密鍵
        const clientPublicKey = clientKeyPair.public  //Uint8Array(32)のClient公開鍵
        this.clientPrivateKeyBase64 = this.uint8ArrayToBase64(clientPrivateKey)   //ブラウザで確認するため、Base64エンコード
        this.clientPublicKeyBase64 = this.uint8ArrayToBase64(clientPublicKey)   //JSON形式でサーバーに送信するため、Base64エンコード
        console.log(clientPrivateKey)
        console.log(clientPublicKey)
+        const headers = {
+          header: {
+            "Content-Type": "application/json"
+          }
+        }
+        const params = {
+          "publickey": this.clientPublicKeyBase64
+        }
+        await axios.post('http://localhost:5000/TradeKey', params, headers)
+        .then((responses)=>{
+          console.log(responses.data)
+        })
      },
      uint8ArrayToBase64(uint8Array){
        return btoa(String.fromCharCode(...uint8Array))
      },
    }
  }
)

ボタンを押すとバックエンドに送信し、レスポンスがあればコンソールに中身を表示します。
次は、バックエンドを作っていきます。

5 バックエンドの実装

5.1 バックエンドの準備

フロントエンド側のVScodeは閉じないで、新しくVScodeを立ち上げます。
こちらは新しいプロジェクトとして使いたいフォルダを作成してからVScodeでフォルダを開きます。
別のフォルダにmisstems2023advbackendというフォルダを作成し、下記コマンドでgoの環境を作ります。

go mod init misstems2023advbackend

main.goを作成し、下記の通りechoでAPIサーバーを作っていきます。

main.go
package main

import (
	"misstems2023advbackend/model"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(middleware.CORS())

	// ルーティング
	e.GET("/", model.Hello)
	e.Logger.Fatal(e.Start(":5000"))
}

modelフォルダを作り、その下にmethod.goを作ります。main.goで"/"にGETでアクセス、つまりブラウザのアドレスにlocalhost:5000と入力したとき、それに対応するmodel.Hello関数が呼び出されることになります。

model/method.go
package model

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

func Hello(c echo.Context) error {
	return c.JSON(http.StatusOK, "Hello World!")
}

実行→構成を開くを押すと下記でるので、Go Launch Packageを選択すると.vscodeフォルダの下にlaunch.jsonができるので、main.goがいつも選択されるように書き換えます。

.vscode/launch.json
{
    // IntelliSense を使用して利用可能な属性を学べます。
    // 既存の属性の説明をホバーして表示します。
    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch Package",
            "type": "go",
            "request": "launch",
            "mode": "auto",
+            "program": "main.go"
-            "program": "${fileDirname}"
        }
    ]
}

次に実行→デバッグの開始を押すとechoサーバーが立ち上がる事を確認します。

Windows Defender ファイアウォールでブロックされたと出た場合はアクセスを許可します。

ブラウザにlocalhost:5000と打ち込むとHello World!と出ることを確認します。

問題ないことを確認したら赤い□の停止ボタンを押してデバッガーを停止します。

5.2 バックエンド側の共通鍵生成とServer公開鍵の送信準備

Clientから公開鍵を受けるためのエンドポイントを作ります。

main.go
package main

import (
	"misstems2023advbackend/model"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(middleware.CORS())

	// ルーティング
	e.GET("/", model.Hello)
+	e.POST("TradeKey", model.TradeKey)
	e.Logger.Fatal(e.Start(":5000"))
}

modelフォルダの下に新しくstructure.goを作り、PublicKeyを送受信するための構造体を追記します。

model/structure.go
package model

type PublicKey struct {
	PublicKey string `json:"publickey"`  //Base64Stringで送受信
}

最後にmethod.goのTradeKeyにECDHE鍵交換を実装します。
X25519のgolang部分は下記を参考にしました。
https://zenn.dev/satoken/articles/golang-tls1_2_2

model/method.go
package model

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"net/http"

	"github.com/labstack/echo/v4"
	"golang.org/x/crypto/curve25519"
)

func Hello(c echo.Context) error {
	return c.JSON(http.StatusOK, "Hello World!")
}

func randomByte(num int) []byte {
	b := make([]byte, num)
	rand.Read(b)
	return b
}

func TradeKey(c echo.Context) error {
	Base64ClientPublicKey := new(PublicKey) //Client公開鍵を受け取るための構造体を作成
	err := c.Bind(&Base64ClientPublicKey)   //構造体に公開鍵を格納
	if err != nil {
		panic(err)
	}
	Uint8ArrayClientPublicKey, err := base64.StdEncoding.DecodeString(Base64ClientPublicKey.PublicKey) //Base64StringをUint8Arrayに変換
	if err != nil {
		panic(err)
	}

	Uint8ArrayServerPrivateKey := make([]byte, 32) //空の32バイトの[]byte = uint8Arrayを作る。
	rand.Read(Uint8ArrayServerPrivateKey)          //Server秘密鍵として暗号的強度の乱数を作る
	
	Uint8ArrayServerPublicKey, err := curve25519.X25519(Uint8ArrayServerPrivateKey, curve25519.Basepoint) // Server公開鍵を作る
	if err != nil {
		panic(err)
	}
	Base64ServerPublicKey := base64.StdEncoding.EncodeToString(Uint8ArrayServerPublicKey) //Clientに送り返すためにBase64エンコード

	//Serverの秘密鍵と公開鍵を楕円曲線上で掛け算
	curve25519.ScalarBaseMult((*[32]byte)(Uint8ArrayServerPublicKey), (*[32]byte)(Uint8ArrayServerPrivateKey))

	//Clientの公開鍵とServerの秘密鍵から共通鍵を作る
	Uint8ArraySharedKey, err := curve25519.X25519(Uint8ArrayServerPrivateKey, Uint8ArrayClientPublicKey)
	if err != nil {
		panic(err)
	}

	//共通鍵をBase64エンコードする
	Base64SharedKey := base64.StdEncoding.EncodeToString(Uint8ArraySharedKey)
	fmt.Println(Base64SharedKey)

	//Server公開鍵を送り返すために構造体に格納する
	ServerPublicKey := PublicKey{
		Base64ServerPublicKey,
	}

	return c.JSON(http.StatusOK, ServerPublicKey)
}

一通り書いたら

go mod tidy

として、パッケージを追加したらデバッグを実行してサーバーを立ち上げます。

6 フロントエンド側の共通鍵生成

ブラウザでボタンを押すと、VScodeのデバッグコンソールに
と出てくればServerからレスポンスを受け取れている。送り返されたServer公開鍵を使って、Curve25519で共通鍵を生成するように書き換えました。
なお、Base64String→Uint8Arrayへの変換は先ほどと同様下記を参考にしました。
https://scrapbox.io/nwtgck/JavaScriptでUint8Array_⇄_Base64文字列の相互変換

store/store.js
import { defineStore } from 'pinia'
+import { sharedKey, generateKeyPair } from 'curve25519-js';
-import { generateKeyPair } from 'curve25519-js';
import axios from "axios"

export const useStore = defineStore(
  {
    id: 'auth',
    status:{
-      clientPrivateKeyBase64: null,
-      clientPublicKeyBase64 : null,
+      serverPublicKeyBase64 :null,
+      sharedKey: null,
    },
    actions:{
      async tradekey(){
        const typedArray = new Uint8Array(32) //作りたい乱数の型宣言 generateKeyPairのseedとしてUint8Array(32)が指定されている
        const cryptoArray = crypto.getRandomValues(typedArray)  //指定した型で暗号強度の乱数を作る
        const clientKeyPair = generateKeyPair(cryptoArray)    //Curve25519でClient秘密鍵と公開鍵のペアを作る
-        const clientPrivateKey = clientKeyPair.private  //Uint8Array(32)のClient秘密鍵
        const clientPublicKey = clientKeyPair.public  //Uint8Array(32)のClient公開鍵
-        this.clientPrivateKeyBase64 = this.uint8ArrayToBase64(clientPrivateKey)   //ブラウザで確認するため、Base64エンコード
+        const clientPublicKeyBase64 = this.uint8ArrayToBase64(clientPublicKey)   //JSON形式でサーバーに送信するため、Base64エンコード
-        this.clientPublicKeyBase64 = this.uint8ArrayToBase64(clientPublicKey)   //JSON形式でサーバーに送信するため、Base64エンコード
-        console.log(clientPrivateKey)
-        console.log(clientPublicKey)
        const headers = {
          header: {
            "Content-Type": "application/json"
          }
        }
        const params = {
          "publickey": clientPublicKeyBase64,
        }
        await axios.post("http://localhost:5000/TradeKey", params, headers)
        .then((responses)=>{
+          const serverPublicKeyBase64 = responses.data.publickey
+          const serverPublicKey = this.base64ToUint8Array(serverPublicKeyBase64)
+          const Uint8ArraysharedKey = sharedKey(clientKeyPair.private, serverPublicKey)
+          this.sharedKey = this.uint8ArrayToBase64(Uint8ArraysharedKey)
-	console.log(responses.data)
        })
      },
      uint8ArrayToBase64(uint8Array){
        return btoa(String.fromCharCode(...uint8Array))
      },
+      base64ToUint8Array(base64Str) {
+        const raw = atob(base64Str);
+        return Uint8Array.from(Array.prototype.map.call(raw, (x) => { 
+          return x.charCodeAt(0); 
+        })); 
      },
    }
  }
)

views/HomeView.vue
<script setup>
import { reactive } from "vue"
import { useStore } from "@/stores/store"
const store = useStore()
const data = reactive({
-  clientPrivateKey: '',
-  clientPublicKey: '',
+  serverPublicKey: '',
+  sharedKey: ''
})

+const tradekey = async () => {
-const make = async () => {
  await store.tradekey()    //storeのtradekey関数を実行するとstoreにclient秘密鍵とclient公開鍵が格納される。
-  data.clientPrivateKey = store.clientPrivateKeyBase64    //storeからclient秘密鍵を取得
-  data.clientPublicKey = store.clientPublicKeyBase64    //storeからclient公開鍵を取得
+  data.serverPublicKey = store.serverPublicKeyBase64    //storeからserver公開鍵を取得
+  data.sharedKey = store.sharedKey   //storeから共通鍵を取得
}

</script>

<template>
  <main>
    <div>
-      <span>ClientPrivateKey</span><br />
-      <input v-model="data.clientPrivateKey" class="input"><br />
-      <span>ClientPublicKey</span><br />
-      <input v-model="data.clientPublicKey" class="input"><br />
+      <span>ServerPublicKey</span><br />
+      <input v-model="data.ServerPublicKey" class="input"><br />
+      <span>SharedKey</span><br />
+      <input v-model="data.sharedKey" class="input"><br />
    </div><br />
    <div>
+      <button @click="tradekey">Trade Key</button>
-      <button @click="make">Make ClientKeyPair</button>
    </div>
  </main>
</template>

<style scoped>
.input {
  width: 400px;
}
</style>

ボタンを押すとbrowserに下記の通り出ます。

そしてServer側のVScodeのデバッグコンソールにも下記の通り出て、ECDHE鍵交換により生成した共通鍵が両者で一致することを確認しました。

7 Session IDと結び付けた共通鍵の保持

7.1 Redis Serverの利用

Serverは1台に対してClientは多数接続するのが一般的です。従って、Client側は共通鍵はstoreでもcookieでもなんでもよいですが、Server側はどの共通鍵がどのClientと交換したものなのか結びつけた上で保持しておく必要があります。そこでRedis Serverというインメモリ型のKey-Valueストアを用いてSession Idと共通鍵のペアを保持することを考えます。Redis ServerはWindow版は公式には配布されていませんが、Microsoft Archiveにて配布されているのでこれを用いることにします。2016年7月で更新が止まっていますが、動作上問題は無さそうです。
https://github.com/microsoftarchive/redis/releases

今回は検証のみのため、Redis-x64-3.0.504.zipをDLすることにします。なお、サービスに登録したいならmsiの方をインストールしましょう。

ひとまず今回はC:\直下に解凍したフォルダを置きました。解凍すると多数ファイルがありますが、まずredis.window.confをVScodeで開きます。

# requirepass foobared

となっているところを探し、頭の#を取ります。foobaredはRedis Serverに接続するときのパスワードになるので、適当に乱数を作って書き換えることが好ましいです。今回は検討なのでそのままにします。

サービス起動時のポートを変更したいときは、

port 6379

となっているところを探し、変更します。今回はport6378と変更しました。

書き換えたconfを用いてRedis Serverを起動するバッチファイルを作ります。

StartRedisServer.bat
CD C:\Redis-x64-3.0.504
redis-server redis.windows.conf

ダブルクリックすると下記のようなwindowが出れば立ち上げ成功です。

適宜、デバッグ時にKeyの情報を覗きたいときのため、Redis Serverの中身を見るためのバッチファイルを作ります。ポート番号を変えた場合は後ろに-pオプションで指定します。

StartRedisCli.bat
CD C:\Redis-x64-3.0.504
redis-cli -p 6378

実行後、auth パスワードと入力すると、パスワード認証ができます。

keys *と打つと登録されているkeysが見れますが今は未だ何もデータを登録していないのでemptyと出ます。

7.2 Redis Serverへの接続

golangからRedis Serverに繋げるときにとても便利なパッケージがあるので利用します。
https://github.com/redis/go-redis

redisフォルダを作り、その下にredis.goファイルを作ります。
環境変数のハードコーディングは好ましくないので.envファイルを作ってgodotenvを用いて読み込むようにします。
Redisにデータが登録できなかったり読めなかったらerrを返すようにします。

redis/redis.go
package redis

import (
	"context"
	"os"
	"time"

	"github.com/joho/godotenv"
	"github.com/redis/go-redis/v9"
)

func RedisSet(key string, value string) error {
	godotenv.Load(".env")
	rdb := redis.NewClient(&redis.Options{
		Addr:     os.Getenv("REDIS_SERVER") + ":" + os.Getenv("REDIS_PORT"),
		Password: os.Getenv("REDIS_PASSWORD"),
		DB:       0, // use default DB
	})

	err := rdb.Set(context.Background(), key, value, 1*time.Minute).Err()
	if err != nil {
		panic(err)
	}
	return err
}

func RedisGet(key string) (value string, err error) {
	godotenv.Load(".env")
	rdb := redis.NewClient(&redis.Options{
		Addr:     os.Getenv("REDIS_SERVER") + ":" + os.Getenv("REDIS_PORT"),
		Password: os.Getenv("REDIS_PASSWORD"),
		DB:       0, // use default DB
	})

	value, err = rdb.Get(context.Background(), key).Result()
	if err != nil {
		panic(err)
	}
	return value, err
}

func RedisDel(key string) error {
	godotenv.Load(".env")
	rdb := redis.NewClient(&redis.Options{
		Addr:     os.Getenv("REDIS_SERVER") + ":" + os.Getenv("REDIS_PORT"),
		Password: os.Getenv("REDIS_PASSWORD"),
		DB:       0, // use default DB
	})

	err := rdb.Del(context.Background(), key).Err()
	if err != nil {
		panic(err)
	}
	return err
}
.env
REDIS_SERVER = localhost
REDIS_PORT = 6378
REDIS_PASSWORD = foobared

つまり、Redis ServerのValueとして共通鍵を保管すればよいということになります。では、一意であるKeyはどのように決めていくとよいでしょうか。
適当な暗号強度の乱数を使ってもよいですが、今回はgoogleから便利なuuidというパッケージがあるので利用することにします。

method.go
package model

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
+	"misstems2023advbackend/redis"
	"net/http"

+	"github.com/google/uuid"

	"github.com/labstack/echo/v4"
	"golang.org/x/crypto/curve25519"
)

func Hello(c echo.Context) error {
	return c.JSON(http.StatusOK, "Hello World!")
}

func randomByte(num int) []byte {
	b := make([]byte, num)
	rand.Read(b)
	return b
}

func TradeKey(c echo.Context) error {
	Base64ClientPublicKey := new(PublicKey) //Client公開鍵を受け取るための構造体を作成
	err := c.Bind(&Base64ClientPublicKey)   //構造体に公開鍵を格納
	if err != nil {
		panic(err)
	}
	Uint8ArrayClientPublicKey, err := base64.StdEncoding.DecodeString(Base64ClientPublicKey.PublicKey) //Base64StringをUint8Arrayに変換
	if err != nil {
		panic(err)
	}

	Uint8ArrayServerPrivateKey := make([]byte, curve25519.ScalarSize) //空の32バイトの[]byte = uint8Arrayを作る。
	rand.Read(Uint8ArrayServerPrivateKey)                             //Server秘密鍵として暗号的強度の乱数を作る

	Uint8ArrayServerPublicKey, err := curve25519.X25519(Uint8ArrayServerPrivateKey, curve25519.Basepoint) // Server公開鍵を作る
	if err != nil {
		panic(err)
	}
	Base64ServerPublicKey := base64.StdEncoding.EncodeToString(Uint8ArrayServerPublicKey) //Clientに送り返すためにBase64エンコード

	//Serverの秘密鍵と公開鍵を楕円曲線上で掛け算
	curve25519.ScalarBaseMult((*[32]byte)(Uint8ArrayServerPublicKey), (*[32]byte)(Uint8ArrayServerPrivateKey))

	//Clientの公開鍵とServerの秘密鍵から共通鍵を作る
	Uint8ArraySharedKey, err := curve25519.X25519(Uint8ArrayServerPrivateKey, Uint8ArrayClientPublicKey)
	if err != nil {
		panic(err)
	}

	//共通鍵をBase64エンコードする
	Base64SharedKey := base64.StdEncoding.EncodeToString(Uint8ArraySharedKey)
	fmt.Println(Base64SharedKey)

+	Sessionid := uuid.NewString() //ランダムなSessionidを作る

+	err = redis.RedisSet(Sessionid, Base64SharedKey) //RedisにSessionidとBase64SharedKeyのペアを登録する
	if err != nil {
		panic(err)
	}

+	Sessionid := uuid.NewString() //ランダムなSessionidを作る

+	err = redis.RedisSet(Sessionid, Base64SharedKey) //RedisにSessionidとBase64SharedKeyのペアを登録する
+	if err != nil {
+		panic(err)
+	}

	//SessionidとServer公開鍵を送り返すために構造体に格納する
	+ServerPublicKey := WithSessionIdPublicKey{
	-ServerPublicKey := PublicKey{
		Base64ServerPublicKey,
		Sessionid,
	}

	return c.JSON(http.StatusOK, ServerPublicKey)
}
model/structure.go
package model

type PublicKey struct {
	PublicKey string `json:"publickey"` //Base64Stringで送ってくる
}

+type WithSessionPublicKey struct {
+	PublicKey string `json:"publickey"` //Base64Stringで送ってくる
+	Sessionid string `json:"sessionid"` //Base64Stringで送ってくる
}

7.3 Cookieを用いたSession IDの保存

今回はBrowser側は受け取ったSession IDをCookieに保存することにします。
大変便利なCookieのライブラリがあるので利用することにします。
https://github.com/js-cookie/js-cookie

npm install js-cookie
store/store.js
import { defineStore } from 'pinia'
import { sharedKey, generateKeyPair } from 'curve25519-js';
import axios from "axios"
+import Cookies from 'js-cookie'
export const useStore = defineStore(
  {
    id: 'auth',
    status:{
      serverPublicKeyBase64 :null,
      sharedkey: null,
    },
    actions:{
      async tradekey(){
        const typedArray = new Uint8Array(32) //作りたい乱数の型宣言 generateKeyPairのseedとしてUint8Array(32)が指定されている
        const cryptoArray = crypto.getRandomValues(typedArray)  //指定した型で暗号強度の乱数を作る
        const clientKeyPair = generateKeyPair(cryptoArray)    //Curve25519でClient秘密鍵と公開鍵のペアを作る
        const clientPublicKey = clientKeyPair.public  //Uint8Array(32)のClient公開鍵
        const clientPublicKeyBase64 = this.uint8ArrayToBase64(clientPublicKey)   //JSON形式でサーバーに送信するため、Base64エンコード
        const headers = {
          header: {
            "Content-Type": "application/json"
          }
        }
        const params = {
          "publickey":  clientPublicKeyBase64,
        }
        await axios.post("http://localhost:5000/TradeKey", params, headers)
        .then((responses)=>{
          this.serverPublicKeyBase64 = responses.data.publickey
          const serverPublicKey = this.base64ToUint8Array(this.serverPublicKeyBase64)
          const Uint8ArraysharedKey = sharedKey(clientKeyPair.private, serverPublicKey)
          this.sharedkey = this.uint8ArrayToBase64(Uint8ArraysharedKey)
+          const jsonData = JSON.stringify(responses.data.sessionid)
+          Cookies.set("sessionid", jsonData, { expires: 1/1440 })  //expiresは1日単位なので分に直したいときは1440で割る
        })
      },
      uint8ArrayToBase64(uint8Array){
        return btoa(String.fromCharCode(...uint8Array))
      },
      base64ToUint8Array(base64Str) {
        const raw = atob(base64Str);
        return Uint8Array.from(Array.prototype.map.call(raw, (x) => { 
          return x.charCodeAt(0); 
        })); 
      }
    }
  }
)

ここまで打ち込んだら、TradeKeyボタンを押したら、右上の・・・→その他のツール→開発者ツールを開き、アプリケーションタブ→Cookie→localhost:5173にcookieが保存されていることを確認します。

8 AES-GCM暗号化、復号の実装と結果確認

8.1 Browser側のAES-GCM暗号化、復号の実装

Browser側でテキストを打ち込んだら、上記のECDHE鍵交換で得られた共通鍵を用いて、AES-GCM暗号化と復号をするようにします。

Encryptボタンを押すと、Homeview.vueからauth.jsのtradekeyにより鍵交換後、aesgcmEncrypt関数を呼び出します。Browserで暗号化して、それを後で実装するServer側のDecryptエンドポイントに渡します。そうするとServer側で復号された平文が帰ってくることを確認でします。
Decryptボタンを押すと、Homeview.vueからauth.jsのtradekeyにより鍵交換後、aesgcmDecrypt関数を呼び出します。BrowserからServerに平文を送り、それを後で実装するServer側のEncryptエンドポイントに渡します。そうすると、Server側で暗号化した文が送り返されるので、Browser側で復号できることを確認します。

store.js
import { defineStore } from 'pinia'
import { sharedKey, generateKeyPair } from 'curve25519-js';
import axios from "axios"
import Cookies from "js-cookie"
+import { gcm } from '@noble/ciphers/aes';
+import { utf8ToBytes ,bytesToUtf8 } from '@noble/ciphers/utils';
+import { randomBytes } from '@noble/ciphers/webcrypto/utils';
+import { pbkdf2, createSHA256 } from 'hash-wasm';
export const useStore = defineStore(
  {
    id: 'auth',
    status:{
      sharedkey: null,
+      decryptdata:null,
+      cipherdata:null,
    },
    actions:{
      async tradekey(){
        const typedArray = new Uint8Array(32) //作りたい乱数の型宣言 generateKeyPairのseedとしてUint8Array(32)が指定されている
        const cryptoArray = crypto.getRandomValues(typedArray)  //指定した型で暗号強度の乱数を作る
        const clientKeyPair = generateKeyPair(cryptoArray)    //Curve25519でClient秘密鍵と公開鍵のペアを作る
        const clientPublicKey = clientKeyPair.public  //Uint8Array(32)のClient公開鍵
        const clientPublicKeyBase64 = this.uint8ArrayToBase64(clientPublicKey)   //JSON形式でサーバーに送信するため、Base64エンコード
        const headers = {
          header: {
            "Content-Type": "application/json"
          }
        }
        const params = {
          "publickey": clientPublicKeyBase64,
        }
        await axios.post("http://localhost:5000/TradeKey", params, headers)
        .then((responses)=>{
          const serverPublicKeyBase64 = responses.data.publickey
          const serverPublicKey = this.base64ToUint8Array(serverPublicKeyBase64)
          const Uint8ArraysharedKey = sharedKey(clientKeyPair.private, serverPublicKey)
          this.sharedkey = this.uint8ArrayToBase64(Uint8ArraysharedKey)
          const jsonData = JSON.stringify(responses.data.sessionid)
          Cookies.set("sessionid", jsonData, { expires: 1/1440 })  //expiresは1日単位なので分に直したいときは1440で割る
        })
      },
      
+      async SendCipherData(cipherdata){
+        const jsonData = Cookies.get("sessionid") 
+        const Sessionid = JSON.parse(jsonData)
+        const headers = {
+          header: {
+            "Content-Type": "application/json"
+          }
+        }
+        const params = {
+          "sessionid" : Sessionid,
+          "cipherDataBase64" :cipherdata.cipherDataBase64,
+          "saltBase64" :cipherdata.saltBase64,
+          "nonceBase64" :cipherdata.nonceBase64,
+        }
+        await axios.post("http://localhost:5000/Decrypt", params, headers)
+        .then((responses)=>{
+          Cookies.remove("sessionid")    //1回使ったsessionidを保存しているCookieは削除
+          this.decryptdata = responses.data
+        })
+      },

+      async SendPlainData(plaindata){
+        const jsonData = Cookies.get("sessionid") 
+        const Sessionid = JSON.parse(jsonData)
+        const headers = {
+          header: {
+            "Content-Type": "application/json"
+          }
+        }
+        const params = {
+          "sessionid" : Sessionid,
+          "plaindata" :plaindata
+        }
+        await axios.post("http://localhost:5000/Encrypt", params, headers)
+        .then((responses)=>{
+          Cookies.remove("sessionid")    //1回使ったsessionidを保存しているCookieは削除
+          this.cipherdata = responses.data
+        })
+      },

      uint8ArrayToBase64(uint8Array){
        return btoa(String.fromCharCode(...uint8Array))
      },
      base64ToUint8Array(base64Str) {
        const raw = atob(base64Str);
        return Uint8Array.from(Array.prototype.map.call(raw, (x) => { 
          return x.charCodeAt(0); 
        })); 
      },
      
+      async aesgcmEncrypt(plaindata, sharedKey){
+        const uint8ArraysharedKey = this.base64ToUint8Array(sharedKey)
+        const uint8ArrayData = utf8ToBytes(plaindata)
+        const uint8ArraySalt = randomBytes(16)
+        const uint8ArrayNonce = randomBytes(12)
+        const uint8ArrayDeriveKey = await pbkdf2({
+          password: uint8ArraysharedKey,
+          salt:uint8ArraySalt,
+          iterations: 1000,
+          hashLength: 32,
+          hashFunction: createSHA256(),
+          outputType: 'binary',
+        });
+        const aes = gcm(uint8ArrayDeriveKey, uint8ArrayNonce);
+        const cipherdata = aes.encrypt(uint8ArrayData);

+        const cipherDataBase64 = this.uint8ArrayToBase64(cipherdata)
+        const saltBase64 = this.uint8ArrayToBase64(uint8ArraySalt)
+        const nonceBase64 = this.uint8ArrayToBase64(uint8ArrayNonce)
+        return {cipherDataBase64, saltBase64, nonceBase64}
+      },

+      async aesgcmDecrypt(cipherDataBase64, saltBase64, nonceBase64, sharedKey){
+        const uint8ArraysharedKey = this.base64ToUint8Array(sharedKey)
+        const uint8ArrayCipherdata = this.base64ToUint8Array(cipherDataBase64)
+        const uint8ArraySalt = this.base64ToUint8Array(saltBase64)
+        const uint8ArrayNonce = this.base64ToUint8Array(nonceBase64)
+        const uint8ArrayDeriveKey = await pbkdf2({
+          password:uint8ArraysharedKey,
+          salt:uint8ArraySalt,
+          iterations: 1000,
+          hashLength: 32,
+          hashFunction: createSHA256(),
+          outputType: 'binary',
+        });
+        const aes = gcm(uint8ArrayDeriveKey, uint8ArrayNonce);
+        const decryptdata = aes.decrypt(uint8ArrayCipherdata)
+        return bytesToUtf8(decryptdata)
+      },
+    }
+  }
+)
views/Homeview.vue
<script setup>
import { reactive } from "vue"
import { useStore } from "@/stores/store"
const store = useStore()
const data = reactive({
-  serverPublicKey: '',
  sharedKey: '',
+  plaindata: 'テキスト',
+  cipherdata: '',
+  salt: '',
+  nonce: '',
+  decryptdata: '',
})

-const tradekey = async () => {
-  data.serverPublicKey = store.serverPublicKeyBase64    //storeからserver公開鍵を取得
-  data.sharedKey = store.sharedKey   //storeから共通鍵を取得
-}

+const encrypt = async () => {
+  await store.tradekey()    //storeのtradekey関数を実行するとstoreにclient秘密鍵とclient公開鍵が格納される。
+  data.sharedKey = store.sharedkey   //storeから共通鍵を取得
+  let cipherdata = await store.aesgcmEncrypt(data.plaindata, data.sharedKey) //暗号化
+  data.cipherdata = cipherdata.cipherDataBase64
+  data.salt = cipherdata.saltBase64
+  data.nonce = cipherdata.nonceBase64
+  await store.SendCipherData(cipherdata)  //暗号文送信→Serverから復号された文を受け取る
+  data.decryptdata = store.decryptdata
+}

+const decrypt = async () => {
+  await store.tradekey()    //storeのtradekey関数を実行するとstoreにclient秘密鍵とclient公開鍵が格納される。
+  data.sharedKey = store.sharedkey   //storeから共通鍵を取得
+  await store.SendPlainData(data.plaindata) //平文送信→Serverから暗号文を受け取る
+  data.cipherdata = store.cipherdata.cipherDataBase64
+  data.salt = store.cipherdata.saltBase64
+  data.nonce = store.cipherdata.nonceBase64
+  data.decryptdata = await store.aesgcmDecrypt(data.cipherdata, data.salt, +data.nonce, data.sharedKey) //復号
+}

</script>

<template>
  <main>
    <div>
      <span>plaindata</span><br />
      <input v-model="data.plaindata" class="input"><br />
      <span>cipherdata</span><br />
      <input v-model="data.cipherdata" class="input"><br />
      <span>salt</span><br />
      <input v-model="data.salt" class="input"><br />
      <span>nonce</span><br />
      <input v-model="data.nonce" class="input"><br />
      <span>decryptdata</span><br />
      <input v-model="data.decryptdata" class="input"><br />
    </div><br />
    <div>
-      <button @click="tradekey">Trade Key</button>
+      <button @click="encrypt">Encrypt</button>
    </div>
+    <br />
+    <div>
+      <button @click="decrypt">Decrypt</button>
+    </div>
  </main>
</template>

<style scoped>
.input {
  width: 400px;
}
</style>

8.2 Server側のAES-GCM暗号化、復号の実装

main.goに暗号化された文を受信して復号するためのDecryptのエンドポイントを作り、平文を受信して暗号化するEncryptのエンドポイントを作ります。
Structure.goにはBrowserから送られてきたSessionID、暗号化された文、salt、nonceの4つを受けるためのCipher構造体と、SessionID、平文を受けるためのplain構造体を追加します。

AES-GCM暗号化と復号の処理はmethod.goに実装しました。
交換した共通鍵を鍵導出関数PBKDF2により1000回ストレッチングする処理を加えています。

Session Idは一度使ったらRedis Serverから削除します。つまり、毎回ECDHEで鍵交換する事になります。

main.go
package main

import (
	"misstem2023adv/model"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {

	e := echo.New()
	e.Binder = model.NewBinder()
	e.Use(middleware.Recover())
	e.Use(middleware.CORS())

	// ルーティング
	e.GET("/", model.Hello)
	e.POST("TradeKey", model.TradeKey)
+	e.POST("Decrypt", model.Decrypt)
+	e.POST("Encrypt", model.Encrypt)
	e.Logger.Fatal(e.Start(":5000"))
}
model/structure.go
package model

type PublicKey struct {
	PublicKey string `json:"publickey"` //Base64Stringで送ってくる
}

type WithSessionPublicKey struct {
	PublicKey string `json:"publickey"` //Base64Stringで送ってくる
	SessionID string `json:"sessionid"` //Base64Stringで送ってくる
}

+type Cipher struct {
+	Sessionid  string `json:"sessionid"`
+	CipherData string `json:"cipherDataBase64"` //Base64Stringで送ってくる
+	Salt       string `json:"saltBase64"`       //Base64Stringで送ってくる
+	Nonce      string `json:"nonceBase64"`      //Base64Stringで送ってくる
+}

+type PlainData struct {
+	Sessionid string `json:"sessionid"`
+	PlainData string `json:"plaindata"`
+}
model/method.go
package model

import (
+	"crypto/aes"
+	"crypto/cipher"
	"crypto/rand"
+	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"misstem2023adv/redis"
	"net/http"

	"github.com/google/uuid"

	"github.com/labstack/echo/v4"
	"golang.org/x/crypto/curve25519"
+	"golang.org/x/crypto/pbkdf2"
)

func Hello(c echo.Context) error {
	return c.JSON(http.StatusOK, "Hello World!")
}

func randomByte(num int) []byte {
	b := make([]byte, num)
	rand.Read(b)
	return b
}

func TradeKey(c echo.Context) error {
	Base64ClientPublicKey := new(PublicKey) //Client公開鍵を受け取るための構造体を作成
	err := c.Bind(&Base64ClientPublicKey)   //構造体に公開鍵を格納
	if err != nil {
		panic(err)
	}
	Uint8ArrayClientPublicKey, err := base64.StdEncoding.DecodeString(Base64ClientPublicKey.PublicKey) //Base64StringをUint8Arrayに変換
	if err != nil {
		panic(err)
	}

	Uint8ArrayServerPrivateKey := make([]byte, curve25519.ScalarSize) //空の32バイトの[]byte = uint8Arrayを作る。
	rand.Read(Uint8ArrayServerPrivateKey)                             //Server秘密鍵として暗号的強度の乱数を作る

	Uint8ArrayServerPublicKey, err := curve25519.X25519(Uint8ArrayServerPrivateKey, curve25519.Basepoint) // Server公開鍵を作る
	if err != nil {
		panic(err)
	}
	Base64ServerPublicKey := base64.StdEncoding.EncodeToString(Uint8ArrayServerPublicKey) //Clientに送り返すためにBase64エンコード

	//Serverの秘密鍵と公開鍵を楕円曲線上で掛け算
	curve25519.ScalarBaseMult((*[32]byte)(Uint8ArrayServerPublicKey), (*[32]byte)(Uint8ArrayServerPrivateKey))

	//Clientの公開鍵とServerの秘密鍵から共通鍵を作る
	Uint8ArraySharedKey, err := curve25519.X25519(Uint8ArrayServerPrivateKey, Uint8ArrayClientPublicKey)
	if err != nil {
		panic(err)
	}

	//共通鍵をBase64エンコードする
	Base64SharedKey := base64.StdEncoding.EncodeToString(Uint8ArraySharedKey)
	fmt.Println(Base64SharedKey)

	Sessionid := uuid.NewString() //ランダムなSessionidを作る

	err = redis.RedisSet(Sessionid, Base64SharedKey) //RedisにSessionidとBase64SharedKeyのペアを登録する
	if err != nil {
		panic(err)
	}

	//Server公開鍵を送り返すために構造体に格納する
	ServerPublicKey := WithSessionPublicKey{
		Base64ServerPublicKey,
		Sessionid,
	}

	return c.JSON(http.StatusOK, ServerPublicKey)
}

+func Decrypt(c echo.Context) error {
+	cipherdata := new(Cipher)  //暗号文を受け取るための構造体を作成
+	err := c.Bind(&cipherdata) //構造体に暗号文とsaltとnonceを格納
+	if err != nil {
+		panic(err)
+	}
+
+	Base64SharedKey, err := redis.RedisGet(cipherdata.Sessionid) //Sessionidから共通鍵取得
+	if err != nil {
+		panic(err)
+	}

+	err = redis.RedisDel(cipherdata.Sessionid) //一度使ったSessionidはRedis Serverから削除
+	if err != nil {
+		panic(err)
+	}

+	Uint8ArraySharedkey, err := base64.StdEncoding.DecodeString(Base64SharedKey)
+	if err != nil {
+		panic(err)
+	}

+	Uint8Arraycipherdata, err := base64.StdEncoding.DecodeString(cipherdata.CipherData)
+	if err != nil {
+		panic(err)
+	}

+	Uint8Arraynonce, err := base64.StdEncoding.DecodeString(cipherdata.Nonce)
+	if err != nil {
+		panic(err)
+	}

+	Uint8Arraysalt, err := base64.StdEncoding.DecodeString(cipherdata.Salt)
+	if err != nil {
+		panic(err)
+	}

+	basekey := pbkdf2.Key(Uint8ArraySharedkey, Uint8Arraysalt, 1000, 32, sha256.New) //pbkdf2で1000回ハッシュ化

+	block, err := aes.NewCipher(basekey)
+	if err != nil {
+		panic(err.Error())
+	}

+	aesgcm, err := cipher.NewGCM(block)
+	if err != nil {
+		panic(err.Error())
+	}

+	uint8Arrayplaindata, err := aesgcm.Open(nil, Uint8Arraynonce, Uint8Arraycipherdata, nil)
+	if err != nil {
+		panic(err.Error())
+	}
+	plaindata := string(uint8Arrayplaindata) //uint8Arrayで復号されるのでテキストに戻す

+	return c.JSON(http.StatusOK, plaindata) //今回は検証なので、復号したデータをそのまま送信
+}

+func Encrypt(c echo.Context) error {
+	plaindata := new(PlainData) //
+	err := c.Bind(&plaindata)   //構造体にBrowserから送られてきた平文とSessionidを格納
+	if err != nil {
+		panic(err)
+	}

+	Base64SharedKey, err := redis.RedisGet(plaindata.Sessionid) //Sessionidから共通鍵取得
+	if err != nil {
+		panic(err)
+	}

+	err = redis.RedisDel(plaindata.Sessionid) //一度使ったSessionidはRedis Serverから削除

+	Uint8ArraySharedkey, err := base64.StdEncoding.DecodeString(Base64SharedKey)
+	if err != nil {
+		panic(err)
+	}

+	Uint8Arraynonce := randomByte(12)

+	Uint8Arraysalt := randomByte(16)

+	basekey := pbkdf2.Key(Uint8ArraySharedkey, Uint8Arraysalt, 1000, 32, sha256.New) //pbkdf2で1000回ハッシュ化

+	block, err := aes.NewCipher(basekey)
+	if err != nil {
+		panic(err.Error())
+	}

+	aesgcm, err := cipher.NewGCM(block)
+	if err != nil {
+		panic(err.Error())
+	}

+	uint8Arraycipherdata := aesgcm.Seal(nil, Uint8Arraynonce, []byte(plaindata.PlainData), nil)
+	cipherdata := Cipher{
+		"Sessionid",
+		base64.StdEncoding.EncodeToString(uint8Arraycipherdata),
+		base64.StdEncoding.EncodeToString(Uint8Arraysalt),
+		base64.StdEncoding.EncodeToString(Uint8Arraynonce),
+	}

+	return c.JSON(http.StatusOK, cipherdata)
+}

参考までに最終的なフォルダ構成を記しておきます。

フロントエンド側のフォルダ構成

misstems2023advfrontend
│  .eslintrc.cjs
│  .gitignore
│  .prettierrc.json
│  index.html
│  list.txt
│  package-lock.json
│  package.json
│  README.md
│  vite.config.js
│  
├─.vscode
│      extensions.json
│      launch.json
│      
├─node_modules
│      割愛
│          
├─public
│      favicon.ico
│      
└─src
    │  App.vue
    │  main.js
    │  
    ├─assets
    │      base.css
    │      logo.svg
    │      main.css
    │      
    ├─components
    │  └─icons
    ├─router
    │      index.js
    │      
    ├─stores
    │      store.js
    │      
    └─views
            HomeView.vue

バックエンド側のフォルダ構成

misstems2023advbackend
│  .env
│  go.mod
│  go.sum
│  main.go
│  
├─.vscode
│      launch.json
│      
├─model
│      custombinder.go
│      method.go
│      struct.go
│
├─redis
│      redis.go
│
└─tmp
        main.exe

8.3 AES-GCM暗号化と復号の結果確認

AES-GCM暗号化の結果を確認しましょう。
Browserのinputにみすてむずで流行っている謎の言葉「ピギモンゴ」を入力します。
Encryptボタンを押すとECDHE鍵交換を行い、そのまま続けてplaindataに入力したデータ「ピギモンゴ」を、Browser側でAES-GCM暗号化して64ビットエンコードしたcipherdata、salt、nonceのデータをServerに送信します。すると、Server側で復号されて、平文のデータ「ピギモンゴ」が戻ってきて、Browserのdecryptdataに表示されていることが確認できました。

AES-GCM復号の結果を確認しましょう。
同様に、Browserのinputにみすてむずで暗躍している「じんるい」を入力します。
Decryptボタンを押すと、ECDHE鍵交換を行い、そのまま続けてplaindataに入力したデータ「じんるい」を平文のままServerに送信します。そうすると、Server側でAES-GCM暗号化して64ビットエンコードされたcipherdata、salt、nonceのデータが戻ってきます。それをBrowser側で復号して、そのデータ「じんるい」がBrowserのdecryptdataに表示されていることが確認できました。

何度かボタンを押すと、同じ入力データでもcipherdata、salt、nonceいずれも変化します。しかし復号したデータは変化せず、暗号化と復号ができていることが分かります。

デバッグ機能を使って、フロントエンド、バックエンドをそれぞれ1行ずつ進めて、変数の値の変化を見てみるのも良いでしょう。

9 さいごに

Client側はJavascript(Vue CompositionAPI)、Server側はGolangを用い、まず相互にECDHE鍵交換により共通鍵を作成して、次に共通鍵について鍵導出関数PBKDF2でストレッチングを行い、最後にストレッチングした鍵、salt、及びnonceを使ってAES-GCMでデータを暗号化と復号するという流れをwebアプリ化してみました。

javascriptライブラリの@noble/cipherと、golangライブラリのcrypto/cipherは、いずれもTLS1.3で使われる暗号スイートのうち、AES-CCMやchacha20-poly1305も使えるようですし、鍵導出関数もPBKDF2よりもbcryptoやargon2idの方がより好ましいのですが、こちらもhash-wasmライブラリで使えるようなので、いずれこれらの検討もやってみたいと思います。

もし間違っている箇所や動かないなどがありましたら、ご指摘頂けると幸いです。
よろしくお願いいたします。

明日の記事はsnowさんです。

Discussion