Création d'un composable pour la gestion de la crypto, mise en place de l'initialisation de la première liste et sauvegarde de celle-ci. TODO NEXT : Récupération de la liste en la déchiffrant, mise en place d'une masterKey dérivée du mot de passe pour les utilisateurs non google, basculement vers la page des listes après connexion google etc.

This commit is contained in:
2026-03-15 08:15:42 +01:00
parent d9d84634e8
commit 5d582461e2
10 changed files with 562 additions and 29 deletions

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import {
useTokenClient,
type TokenClientSuccessResponse,
type TokenClientErrorResponse
} from "vue3-google-signin";
const authStore = useAuthStore();
const listStore = useListStore();
const crypto = useCryptoStore()
/**
* Logique Google Drive (appDataFolder)
*/
const handleDriveKey = async (accessToken: string) => {
const fileName = 'list_master_key';
const baseUrl = 'https://www.googleapis.com/drive/v3/files';
const authHeader = { Authorization: `Bearer ${accessToken}` };
try {
const searchParams = new URLSearchParams({
q: `name = '${fileName}'`,
spaces: 'appDataFolder',
fields: 'files(id, name)'
});
const searchRes = await $fetch<any>(`${baseUrl}?${searchParams}`, { headers: authHeader });
if (searchRes.files && searchRes.files.length > 0) {
const fileId = searchRes.files[0].id;
// 1. Récupérer le contenu en tant que Blob ou ArrayBuffer pour préserver les bytes
const blob = await $fetch<Blob>(`${baseUrl}/${fileId}?alt=media`, {
headers: authHeader,
responseType: 'blob'
});
// Convertir le Blob en Uint8Array
const arrayBuffer = await blob.arrayBuffer();
const keyUint8 = new Uint8Array(arrayBuffer);
listStore.setMasterKey(keyUint8);
console.log("Clé binaire récupérée du Drive : " + keyUint8);
} else {
// 2. Création avec le type Uint8Array
const newKey = crypto.generateMasterKey();
const metadata = {
name: fileName,
parents: ['appDataFolder']
};
const formData = new FormData();
// On spécifie explicitement 'application/octet-stream' pour la clé binaire
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
formData.append('file', new Blob([newKey as any], { type: 'application/octet-stream' }));
await $fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
method: 'POST',
headers: authHeader,
body: formData
});
listStore.setMasterKey(newKey);
console.log("Nouvelle clé binaire générée et stockée : " + newKey);
}
} catch (err) {
console.error("Erreur Google Drive:", err);
}
};
/**
* Succès de l'authentification Google
*/
const handleSuccess = async (response: TokenClientSuccessResponse) => {
const accessToken = response.access_token;
console.log(response.access_token)
try {
// // A. Vérification côté WordPress (Login)
// // Note : On envoie l'accessToken ou on utilise l'ID Token si configuré.
// // Si ton API WP attendait un 'code', il faudra l'adapter pour vérifier l'access_token ou l'email.
const data = await $fetch<any>('/api-wp/auth/google', {
method: 'POST',
body: { access_token: accessToken }
});
// Stockage des infos WP
authStore.setTokenCookie(data.token);
authStore.setUserCookie(data.user);
// B. Gestion de la clé sur Google Drive (Entièrement géré par le Front)
await handleDriveKey(accessToken);
listStore.processRawData(data.lists);
} catch (error) {
console.error("Erreur login WordPress:", error);
}
};
const handleError = (error: TokenClientErrorResponse) => {
console.error("Erreur Google:", error);
};
// Initialisation du Token Client (Implicit Flow - Pas de secret nécessaire)
const { login } = useTokenClient({
scope: 'openid email profile https://www.googleapis.com/auth/drive.appdata',
onSuccess: handleSuccess,
onError: handleError,
});
</script>
<template>
<button @click="() => login()" class="g-signin-button">
<div class="g-logo-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="g-logo">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
<path fill="none" d="M0 0h48v48H0z"/>
</svg>
</div>
<span class="g-button-text">{{ $t('googleBtnTxt') }}</span>
</button>
</template>
<style scoped>
.g-signin-button {
margin:auto;
margin-top:1em;
display: flex;
align-items: center;
justify-content: flex-start;
background-color: #ffffff;
color: #757575;
height: 40px;
width: auto;
border: 1px solid #dadce0;
border-radius: 4px;
padding: 0em;
font-family: 'Roboto', arial, sans-serif;
font-weight: 500;
font-size: 14px;
cursor: pointer;
box-shadow: 0 1px 2px 0 rgba(60,64,67,0.302), 0 1px 3px 1px rgba(60,64,67,0.149);
transition: background-color .218s, border-color .218s, box-shadow .218s;
overflow: hidden;
}
.g-signin-button:hover {
background-color: hsl(210, 17%, 90%);
border-color: #d2d2d2;
box-shadow: 0 1px 2px 0 rgba(60,64,67,0.302), 0 1px 3px 1px rgba(60,64,67,0.149);
}
.g-signin-button:active {
background-color: #eeeeee;
}
.g-logo-wrapper {
padding: 11px;
height: 100%;
display: flex;
align-items: center;
}
.g-logo {
width: 18px;
height: 18px;
}
.g-button-text {
padding: 0 12px 0 10px;
white-space: nowrap;
}
</style>

View File

@@ -24,7 +24,7 @@
authStore.setTokenCookie(data.token);
authStore.setUserCookie(data.user);
listStore.lists = data.lists
console.log(listStore.lists) //ok
//console.log(listStore.lists) //ok
} catch (error) {
console.error("Erreur côté WordPress:", error);
}

199
app/composables/crypto.ts Normal file
View File

@@ -0,0 +1,199 @@
import sodium from "libsodium-wrappers"
export type KeyPair = {
publicKey: Uint8Array
privateKey: Uint8Array
}
export type EncryptedPayload = {
nonce: Uint8Array
cipher: Uint8Array
}
export type EncryptedPrivateKey = {
nonce: Uint8Array
cipher: Uint8Array
salt: Uint8Array
}
export const useCryptoStore = () => {
let ready = false
const init = async (): Promise<void> => {
if (!ready) {
await sodium.ready
ready = true
}
}
/**
* USER KEY PAIR
*/
const generateUserKeyPair = (): KeyPair => {
const pair = sodium.crypto_box_keypair()
return {
publicKey: pair.publicKey,
privateKey: pair.privateKey
}
}
/**
* MASTER KEY
*/
const generateMasterKey = (): Uint8Array => {
return sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES
)
}
/**
* ENCRYPT PRIVATE KEY
*/
const encryptPrivateKey = (
privateKey: Uint8Array,
masterKey: Uint8Array
): EncryptedPayload => {
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES
)
const cipher = sodium.crypto_secretbox_easy(
privateKey,
nonce,
masterKey
)
return { nonce, cipher }
}
/**
* DECRYPT PRIVATE KEY
*/
const decryptPrivateKey = (
encrypted: EncryptedPayload,
masterKey: Uint8Array
): Uint8Array => {
return sodium.crypto_secretbox_open_easy(
encrypted.cipher,
encrypted.nonce,
masterKey
)
}
/**
* LIST KEY
*/
const generateListKey = (): Uint8Array => {
return sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES
)
}
/**
* ENCRYPT LIST DATA
*/
const encryptList = (
data: string,
listKey: Uint8Array
): EncryptedPayload => {
const nonce = sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
)
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
sodium.from_string(data),
null,
null,
nonce,
listKey
)
return { nonce, cipher }
}
/**
* DECRYPT LIST DATA
*/
const decryptList = (
encrypted: EncryptedPayload,
listKey: Uint8Array
): string => {
const decrypted = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
null,
encrypted.cipher,
null,
encrypted.nonce,
listKey
)
return sodium.to_string(decrypted)
}
/**
* ENCRYPT LIST KEY FOR USER
*/
const encryptListKeyForUser = (
listKey: Uint8Array,
recipientPublicKey: Uint8Array,
senderPrivateKey: Uint8Array
): EncryptedPayload => {
const nonce = sodium.randombytes_buf(
sodium.crypto_box_NONCEBYTES
)
const cipher = sodium.crypto_box_easy(
listKey,
nonce,
recipientPublicKey,
senderPrivateKey
)
return { nonce, cipher }
}
/**
* DECRYPT LIST KEY
*/
const decryptListKey = (
encrypted: EncryptedPayload,
senderPublicKey: Uint8Array,
recipientPrivateKey: Uint8Array
): Uint8Array => {
return sodium.crypto_box_open_easy(
encrypted.cipher,
encrypted.nonce,
senderPublicKey,
recipientPrivateKey
)
}
return {
init,
generateUserKeyPair,
generateMasterKey,
encryptPrivateKey,
decryptPrivateKey,
generateListKey,
encryptList,
decryptList,
encryptListKeyForUser,
decryptListKey
}
}

View File

@@ -1,20 +0,0 @@
export const useCounter = () => {
const counter = useState('counter', () => 0)
const increment = () => {
counter.value++
}
const decrement = () => {
counter.value--
}
const reset = () => {
counter.value=0
}
return {
counter,
increment,
decrement,
reset
}
}

View File

@@ -89,7 +89,7 @@ const handleFormSubmit = async() => {
</ButtonBase>
</form>
<p>{{ $t('loginGoogle') }}</p>
<ButtonGoogle/>
<ButtonGoogleCode/>
</section>
</template>

View File

@@ -1,5 +1,5 @@
import type { $Fetch } from 'ofetch';
import type { List } from '~/types/lists'
import type { List, WPListEncrypted } from '~/types/lists'
export default class ListRepository {
private fetcher: $Fetch;
@@ -17,6 +17,23 @@ export default class ListRepository {
method: 'POST',
body: data
});
}
async uploadKeys(data: string) {
console.log('uploadEncryptedPrivateKey : data is')
console.log(data)
return await this.fetcher<any>('/lists/PrivKeyCipher', {
method: 'POST',
body: data
})
}
async update(data: WPListEncrypted) {
const string = JSON.stringify(data);
return await this.fetcher<any>('/lists/' + data.id, {
method: 'PUT',
body: string
})
}
}

View File

@@ -1,11 +1,18 @@
import { defineStore } from 'pinia'
import type { List } from '~/types/lists'
import type { List, WPListEncrypted } from '~/types/lists'
import {useCryptoStore} from '~/composables/crypto'
import sodium from "libsodium-wrappers"
const crypto = useCryptoStore()
export const useListStore = defineStore('lists', {
state: () => ({
lists: [] as List[],
loading: false as boolean,
masterKey: null as Uint8Array | null,
publicKey: new Uint8Array() as Uint8Array,
privateKey: new Uint8Array() as Uint8Array,
}),
actions: {
@@ -15,6 +22,9 @@ export const useListStore = defineStore('lists', {
resetLists(){
this.lists = []
},
setMasterKey(key: Uint8Array){
this.masterKey = key
},
async fetchLists() {
// On récupère notre plugin API injecté
@@ -33,8 +43,108 @@ export const useListStore = defineStore('lists', {
} finally {
this.loading = false;
}
},
async processRawData(rawData: [WPListEncrypted]){
const { $api } = useNuxtApp();
await crypto.init()
if (rawData && rawData.length > 0) {
const item = rawData[0];
if (item && this.masterKey){
if (this.isWPListEncrypted(item)) {
if (item.content_cipher === "initialize-me"){
/* 1- Création de la paire de clés (TODO : à déporter ailleurs (Key Store ?)) */
const { publicKey, privateKey } = crypto.generateUserKeyPair()
this.publicKey = publicKey;
this.privateKey = privateKey;
// On les envoie au BO //
/* Chiffrage de la private avec la master : */
const privKeyCipher = crypto.encryptPrivateKey(this.privateKey, this.masterKey)
/* empaquetage */
const payload = {
'user_id': item.user_id, // pour vérification au BO
'private_key_cipher': privKeyCipher,
'public_key': publicKey,
}
/* Et on envoie ! */
await $api.lists.uploadKeys(JSON.stringify(payload))
// On crée la liste //
/* D'abord la clé AES de la liste */
const key = crypto.generateListKey();
/* Puis la liste */
const user_id = Number(item.user_id)
const data: List = {
id: Number(item.id),
user_id: user_id,
aesKey: key,
list_title: 'THE VERY première liste',
list_type: 'basic',
content: '{[\'vide\']}',
is_open: true,
created_at: Date.now(),
updated_at: Date.now(),
}
this.lists[0] = data
/* Et on envoie au BO */
this.syncData(data, this.publicKey, this.privateKey)
}
else{
console.log('coucou : ' + rawData);
}
}
}
}
},
async syncData(decryptedData: List, userPublicKey: Uint8Array, userPrivateKey: Uint8Array) {
const { $api } = useNuxtApp();
const aesKey = decryptedData.aesKey;
await crypto.init();
// 1. On prépare les données (en excluant la clé elle-même du contenu)
const { aesKey: _, ...pureData } = decryptedData;
const stringData = JSON.stringify(pureData);
// 2. Chiffrement du contenu par la clé AES (XChaCha20)
const encryptedContent = crypto.encryptList(stringData, aesKey);
// 3. Chiffrement de la clé AES par la clé Publique (Asymétrique)
// On utilise ici ta fonction de l'étape 11
const encryptedKeyPayload = crypto.encryptListKeyForUser(
aesKey,
userPublicKey,
userPrivateKey
);
// 4. On empaquette le tout pour le Back-Office
const dataToBO: WPListEncrypted = {
id: decryptedData.id.toString(),
user_id: decryptedData.user_id.toString(),
// La clé AES verrouillée pour l'utilisateur
key_cipher: sodium.to_base64(encryptedKeyPayload.cipher),
key_nonce: sodium.to_base64(encryptedKeyPayload.nonce),
// Le contenu de la liste
content_cipher: sodium.to_base64(encryptedContent.cipher),
content_nonce: sodium.to_base64(encryptedContent.nonce),
created_at: null,
updated_at: null,
};
await $api.lists.update(dataToBO)
},
// async updateList(id, title, content) {
// const config = useRuntimeConfig();
@@ -57,6 +167,26 @@ export const useListStore = defineStore('lists', {
// console.error("Erreur lors de la récupération des listes:", error);
// }
// }
isWPListEncrypted(obj: any): obj is WPListEncrypted {
console.log(obj !== null)
console.log(typeof obj)
console.log(typeof obj.id)
console.log(typeof obj.key_cipher )
console.log(typeof obj.key_nonce )
console.log(typeof obj.content_cipher)
console.log(typeof obj.content_nonce)
return (
obj !== null &&
typeof obj === 'object' &&
typeof obj.id === 'string' &&
typeof obj.key_cipher === 'string' &&
typeof obj.key_nonce === 'string' &&
typeof obj.content_cipher === 'string' &&
typeof obj.content_nonce === 'string'
);
}
}
})

View File

@@ -1,11 +1,24 @@
export interface WPListEncrypted {
id: string;
user_id: string;
key_cipher: string; // La "data" est cachée là-dedans sous forme de string chiffrée
key_nonce: string;
content_cipher: string;
content_nonce: string;
created_at: number | null; // Souvent gardé en clair pour le tri côté front
updated_at: number | null;
}
export interface List {
id: number;
user_id: number;
aesKey: Uint8Array;
list_title: string;
list_type: string;
encrypted_content: string; // JSON string
content: string; // JSON string
is_open: boolean;
created_at: string;
updated_at: string;
created_at: number;
updated_at: number;
}
export interface taskItem {

16
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@nuxtjs/i18n": "^10.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"libsodium-wrappers": "^0.8.2",
"nuxt": "^4.2.2",
"nuxt-vue3-google-signin": "^0.0.13",
"pinia": "^3.0.4",
@@ -8736,6 +8737,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/libsodium": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.2.tgz",
"integrity": "sha512-TsnGYMoZtpweT+kR+lOv5TVsnJ/9U0FZOsLFzFOMWmxqOAYXjX3fsrPAW+i1LthgDKXJnI9A8dWEanT1tnJKIw==",
"license": "ISC"
},
"node_modules/libsodium-wrappers": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.2.tgz",
"integrity": "sha512-VFLmfxkxo+U9q60tjcnSomQBRx2UzlRjKWJqvB4K1pUqsMQg4cu3QXA2nrcsj9A1qRsnJBbi2Ozx1hsiDoCkhw==",
"license": "ISC",
"dependencies": {
"libsodium": "^0.8.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View File

@@ -18,6 +18,7 @@
"@nuxtjs/i18n": "^10.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"libsodium-wrappers": "^0.8.2",
"nuxt": "^4.2.2",
"nuxt-vue3-google-signin": "^0.0.13",
"pinia": "^3.0.4",