1 Commits

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,8 +22,11 @@ export const useListStore = defineStore('lists', {
resetLists(){
this.lists = []
},
setMasterKey(key: Uint8Array){
this.masterKey = key
},
async fetchLists() {
async fetchLists() {
// On récupère notre plugin API injecté
const { $api } = useNuxtApp();
this.loading = true;
@@ -33,7 +43,107 @@ 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",