judu/ex-egopublic Fork
⑂ a3b8637
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(webauthn): table passkeys (0015) et module dbUnverified

Le Passkey webauthn-rs entier en JSONB, credential_id en colonne (unicité
globale, lookup, exclusion), ownership en SQL.
Julien Durillon committed 2026-08-26T14:02:10+02:00 Browse files
a3b8637 parent: aeae9bb
added migrations/0015_passkeys.sql +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+-- Clés d'accès WebAuthn (passkeys) : alternative de CONNEXION au mot de
2+-- passe, jamais un remplacement (users.password_hash reste NOT NULL — le mot
3+-- de passe est le filet de récupération).
4+-- La colonne passkey stocke le type Passkey de webauthn-rs ENTIER (JSONB) :
5+-- il porte la clé publique COSE, le compteur de signature et la politique de
6+-- vérification utilisateur — le désérialiser rend l'objet prêt à vérifier.
7+-- credential_id est dupliqué en colonne : unicité GLOBALE (un authenticator
8+-- n'appartient qu'à un compte), lookup au retour d'authentification, et
9+-- liste d'exclusion à l'enregistrement.
10+CREATE TABLE passkeys (
11+ id UUID PRIMARY KEY,
12+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
13+ credential_id BYTEA NOT NULL UNIQUE,
14+ passkey JSONB NOT NULL,
15+ label TEXT NOT NULL,
16+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17+);
18+CREATE INDEX idx_passkeys_user ON passkeys (user_id);
new file mode 100644
@@ -0,0 +1,18 @@
1+-- Clés d'accès WebAuthn (passkeys) : alternative de CONNEXION au mot de
2+-- passe, jamais un remplacement (users.password_hash reste NOT NULL — le mot
3+-- de passe est le filet de récupération).
4+-- La colonne passkey stocke le type Passkey de webauthn-rs ENTIER (JSONB) :
5+-- il porte la clé publique COSE, le compteur de signature et la politique de
6+-- vérification utilisateur — le désérialiser rend l'objet prêt à vérifier.
7+-- credential_id est dupliqué en colonne : unicité GLOBALE (un authenticator
8+-- n'appartient qu'à un compte), lookup au retour d'authentification, et
9+-- liste d'exclusion à l'enregistrement.
10+CREATE TABLE passkeys (
11+ id UUID PRIMARY KEY,
12+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
13+ credential_id BYTEA NOT NULL UNIQUE,
14+ passkey JSONB NOT NULL,
15+ label TEXT NOT NULL,
16+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17+);
18+CREATE INDEX idx_passkeys_user ON passkeys (user_id);
modified src/db/mod.rs +1 -0
@@ -1,4 +1,5 @@
11 pub mod entries;
2+pub mod passkeys;
23 pub mod invitations;
34 pub mod stats;
45 pub mod categories;
@@ -1,4 +1,5 @@
1 pub mod entries;1 pub mod entries;
2+pub mod passkeys;
2 pub mod invitations;3 pub mod invitations;
3 pub mod stats;4 pub mod stats;
4 pub mod categories;5 pub mod categories;
added src/db/passkeys.rs +94 -0
new file mode 100644
@@ -0,0 +1,94 @@
1+//! Clés d'accès WebAuthn d'un compte. Ownership toujours en SQL
2+//! (`WHERE … AND user_id = $n`, 0 ligne touchée → 404 côté handler).
3+
4+use chrono::{DateTime, Utc};
5+use sqlx::PgPool;
6+use uuid::Uuid;
7+use webauthn_rs::prelude::Passkey;
8+
9+#[derive(sqlx::FromRow)]
10+pub struct PasskeyRow {
11+ pub id: Uuid,
12+ pub label: String,
13+ pub created_at: DateTime<Utc>,
14+}
15+
16+/// Les clés d'un compte, pour la page Sécurité — plus ancienne d'abord
17+/// (l'ordre d'enregistrement est celui dont on se souvient).
18+pub async fn list_for(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<PasskeyRow>> {
19+ sqlx::query_as("SELECT id, label, created_at FROM passkeys WHERE user_id = $1 ORDER BY created_at")
20+ .bind(user_id)
21+ .fetch_all(pool)
22+ .await
23+}
24+
25+/// Les identifiants de credential déjà enregistrés — liste d'exclusion à
26+/// l'enregistrement (l'authenticator refuse de se ré-enregistrer).
27+pub async fn credential_ids(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Vec<u8>>> {
28+ let rows: Vec<(Vec<u8>,)> =
29+ sqlx::query_as("SELECT credential_id FROM passkeys WHERE user_id = $1")
30+ .bind(user_id)
31+ .fetch_all(pool)
32+ .await?;
33+ Ok(rows.into_iter().map(|(id,)| id).collect())
34+}
35+
36+pub async fn insert(pool: &PgPool, user_id: Uuid, label: &str, pk: &Passkey) -> sqlx::Result<()> {
37+ let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
38+ sqlx::query(
39+ "INSERT INTO passkeys (id, user_id, credential_id, passkey, label)
40+ VALUES ($1, $2, $3, $4, $5)",
41+ )
42+ .bind(Uuid::now_v7())
43+ .bind(user_id)
44+ .bind(pk.cred_id().as_slice())
45+ .bind(json)
46+ .bind(label)
47+ .execute(pool)
48+ .await?;
49+ Ok(())
50+}
51+
52+/// Tous les Passkey d'un compte, prêts à vérifier une connexion. Une ligne
53+/// illisible est une erreur franche (corruption), pas un skip silencieux.
54+pub async fn passkeys_of(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Passkey>> {
55+ let rows: Vec<(serde_json::Value,)> =
56+ sqlx::query_as("SELECT passkey FROM passkeys WHERE user_id = $1")
57+ .bind(user_id)
58+ .fetch_all(pool)
59+ .await?;
60+ rows.into_iter()
61+ .map(|(v,)| serde_json::from_value(v).map_err(|e| sqlx::Error::Decode(Box::new(e))))
62+ .collect()
63+}
64+
65+/// Réécrit un Passkey après connexion (compteur de signature avancé).
66+/// Le lookup se fait par credential_id : c'est lui que la cérémonie rend.
67+pub async fn update(pool: &PgPool, credential_id: &[u8], pk: &Passkey) -> sqlx::Result<u64> {
68+ let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
69+ let res = sqlx::query("UPDATE passkeys SET passkey = $1 WHERE credential_id = $2")
70+ .bind(json)
71+ .bind(credential_id)
72+ .execute(pool)
73+ .await?;
74+ Ok(res.rows_affected())
75+}
76+
77+pub async fn rename(pool: &PgPool, id: Uuid, user_id: Uuid, label: &str) -> sqlx::Result<u64> {
78+ let res = sqlx::query("UPDATE passkeys SET label = $1 WHERE id = $2 AND user_id = $3")
79+ .bind(label)
80+ .bind(id)
81+ .bind(user_id)
82+ .execute(pool)
83+ .await?;
84+ Ok(res.rows_affected())
85+}
86+
87+pub async fn delete(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
88+ let res = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND user_id = $2")
89+ .bind(id)
90+ .bind(user_id)
91+ .execute(pool)
92+ .await?;
93+ Ok(res.rows_affected())
94+}
new file mode 100644
@@ -0,0 +1,94 @@
1+//! Clés d'accès WebAuthn d'un compte. Ownership toujours en SQL
2+//! (`WHERE … AND user_id = $n`, 0 ligne touchée → 404 côté handler).
3+
4+use chrono::{DateTime, Utc};
5+use sqlx::PgPool;
6+use uuid::Uuid;
7+use webauthn_rs::prelude::Passkey;
8+
9+#[derive(sqlx::FromRow)]
10+pub struct PasskeyRow {
11+ pub id: Uuid,
12+ pub label: String,
13+ pub created_at: DateTime<Utc>,
14+}
15+
16+/// Les clés d'un compte, pour la page Sécurité — plus ancienne d'abord
17+/// (l'ordre d'enregistrement est celui dont on se souvient).
18+pub async fn list_for(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<PasskeyRow>> {
19+ sqlx::query_as("SELECT id, label, created_at FROM passkeys WHERE user_id = $1 ORDER BY created_at")
20+ .bind(user_id)
21+ .fetch_all(pool)
22+ .await
23+}
24+
25+/// Les identifiants de credential déjà enregistrés — liste d'exclusion à
26+/// l'enregistrement (l'authenticator refuse de se ré-enregistrer).
27+pub async fn credential_ids(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Vec<u8>>> {
28+ let rows: Vec<(Vec<u8>,)> =
29+ sqlx::query_as("SELECT credential_id FROM passkeys WHERE user_id = $1")
30+ .bind(user_id)
31+ .fetch_all(pool)
32+ .await?;
33+ Ok(rows.into_iter().map(|(id,)| id).collect())
34+}
35+
36+pub async fn insert(pool: &PgPool, user_id: Uuid, label: &str, pk: &Passkey) -> sqlx::Result<()> {
37+ let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
38+ sqlx::query(
39+ "INSERT INTO passkeys (id, user_id, credential_id, passkey, label)
40+ VALUES ($1, $2, $3, $4, $5)",
41+ )
42+ .bind(Uuid::now_v7())
43+ .bind(user_id)
44+ .bind(pk.cred_id().as_slice())
45+ .bind(json)
46+ .bind(label)
47+ .execute(pool)
48+ .await?;
49+ Ok(())
50+}
51+
52+/// Tous les Passkey d'un compte, prêts à vérifier une connexion. Une ligne
53+/// illisible est une erreur franche (corruption), pas un skip silencieux.
54+pub async fn passkeys_of(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Passkey>> {
55+ let rows: Vec<(serde_json::Value,)> =
56+ sqlx::query_as("SELECT passkey FROM passkeys WHERE user_id = $1")
57+ .bind(user_id)
58+ .fetch_all(pool)
59+ .await?;
60+ rows.into_iter()
61+ .map(|(v,)| serde_json::from_value(v).map_err(|e| sqlx::Error::Decode(Box::new(e))))
62+ .collect()
63+}
64+
65+/// Réécrit un Passkey après connexion (compteur de signature avancé).
66+/// Le lookup se fait par credential_id : c'est lui que la cérémonie rend.
67+pub async fn update(pool: &PgPool, credential_id: &[u8], pk: &Passkey) -> sqlx::Result<u64> {
68+ let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
69+ let res = sqlx::query("UPDATE passkeys SET passkey = $1 WHERE credential_id = $2")
70+ .bind(json)
71+ .bind(credential_id)
72+ .execute(pool)
73+ .await?;
74+ Ok(res.rows_affected())
75+}
76+
77+pub async fn rename(pool: &PgPool, id: Uuid, user_id: Uuid, label: &str) -> sqlx::Result<u64> {
78+ let res = sqlx::query("UPDATE passkeys SET label = $1 WHERE id = $2 AND user_id = $3")
79+ .bind(label)
80+ .bind(id)
81+ .bind(user_id)
82+ .execute(pool)
83+ .await?;
84+ Ok(res.rows_affected())
85+}
86+
87+pub async fn delete(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
88+ let res = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND user_id = $2")
89+ .bind(id)
90+ .bind(user_id)
91+ .execute(pool)
92+ .await?;
93+ Ok(res.rows_affected())
94+}