INOPAY
Retour aux développeurs

Spécification KYC portable Ed25519

Une attestation KYC Inopay est un objet JSON signé Ed25519. Elle est vérifiable hors-ligne par n'importe quel intégrateur, sans appel à un service tiers. Reconnue par la Doctrine CREPMF 2026 comme équivalent à un onboarding traditionnel.

Format JSON de l'attestation

Champs requis et types. La canonisation suit RFC 8785 (JSON Canonicalization Scheme) avant signature Ed25519.

{
  "$schema": "https://schemas.getinopay.com/kyc-attestation/v1.json",
  "type": "object",
  "required": ["version", "subject_id", "level", "issued_at",
               "expires_at", "issuer", "verifications",
               "signature", "key_id"],
  "properties": {
    "version":       { "type": "string", "const": "1.0" },
    "subject_id":    { "type": "string", "description": "Pseudonymous subject id" },
    "level":         { "type": "string", "enum": ["KYC1", "KYC2", "KYC3"] },
    "issued_at":     { "type": "string", "format": "date-time" },
    "expires_at":    { "type": "string", "format": "date-time" },
    "issuer":        { "type": "string", "const": "inopay" },
    "verifications": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "type":       { "enum": ["id_doc", "address", "selfie",
                                   "liveness", "source_of_funds", "pep"] },
          "verified_at":{ "type": "string", "format": "date-time" },
          "method":     { "type": "string" }
        }
      }
    },
    "jurisdictions": { "type": "array", "items": { "enum": ["UEMOA", "CEMAC", "GHANA"] } },
    "signature":     { "type": "string", "description": "Ed25519 signature, base64url" },
    "key_id":        { "type": "string", "description": "e.g. staging-2026-04" }
  }
}

Exemple signé

Attestation tier_2 émise pour la zone UEMOA. La clé publique d'émission est exposée via /.well-known/inopay.

{
  "version":      "1.0",
  "subject_id":   "ino_sub_8a2f9c1d",
  "level":        "KYC2",
  "issued_at":    "2026-04-25T08:00:00Z",
  "expires_at":   "2027-04-25T08:00:00Z",
  "issuer":       "inopay",
  "verifications": [
    { "type": "id_doc",   "verified_at": "2026-04-25T07:58:11Z", "method": "OCR+MRZ" },
    { "type": "selfie",   "verified_at": "2026-04-25T07:58:32Z", "method": "passive-liveness" },
    { "type": "address",  "verified_at": "2026-04-25T07:59:05Z", "method": "utility-bill" }
  ],
  "jurisdictions": ["UEMOA"],
  "signature":    "MEUCIQDk9HVx...redacted...base64url...QqL2",
  "key_id":       "staging-2026-04"
}

Procédure de vérification offline

Aucun appel réseau requis après récupération initiale de la clé publique. Snippets Python, Node et Go ci-dessous.

Python (PyNaCl)
# pip install pynacl requests
import json, base64, requests
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

# 1. Fetch issuer public key (cache locally with rotation hints)
WK = requests.get("https://www.getinopay.com/.well-known/inopay", timeout=5).json()
pub_b64 = next(k["public_key"] for k in WK["keys"] if k["key_id"] == attestation["key_id"])
verify_key = VerifyKey(base64.urlsafe_b64decode(pub_b64 + "=="))

# 2. Canonicalize (RFC 8785 — keys sorted, no whitespace) and verify
payload = {k: v for k, v in attestation.items() if k != "signature"}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
sig = base64.urlsafe_b64decode(attestation["signature"] + "==")

try:
    verify_key.verify(canonical, sig)
    print("OK — attestation signature valid")
except BadSignatureError:
    raise SystemExit("REJECT — invalid signature")
Node.js (libsodium-wrappers)
// npm install libsodium-wrappers node-fetch
import sodium from 'libsodium-wrappers';

await sodium.ready;
const wk = await fetch('https://www.getinopay.com/.well-known/inopay').then(r => r.json());
const pub = wk.keys.find(k => k.key_id === attestation.key_id).public_key;
const pubBytes = sodium.from_base64(pub, sodium.base64_variants.URLSAFE_NO_PADDING);

const { signature, ...payload } = attestation;
const canonical = JSON.stringify(
  Object.fromEntries(Object.entries(payload).sort(([a], [b]) => a.localeCompare(b)))
);
const sigBytes = sodium.from_base64(signature, sodium.base64_variants.URLSAFE_NO_PADDING);

const valid = sodium.crypto_sign_verify_detached(
  sigBytes, sodium.from_string(canonical), pubBytes
);
if (!valid) throw new Error('REJECT — invalid attestation signature');
Go (crypto/ed25519)
// import: encoding/base64 · encoding/json · crypto/ed25519 · net/http
package main

import (
    "crypto/ed25519"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "net/http"
    "sort"
)

func verifyAttestation(att map[string]any) error {
    // 1. Fetch issuer public key from .well-known
    resp, _ := http.Get("https://www.getinopay.com/.well-known/inopay")
    defer resp.Body.Close()
    var wk struct{ Keys []struct{ KeyID, PublicKey string } }
    json.NewDecoder(resp.Body).Decode(&wk)

    var pub ed25519.PublicKey
    for _, k := range wk.Keys {
        if k.KeyID == att["key_id"].(string) {
            b, _ := base64.URLEncoding.DecodeString(k.PublicKey)
            pub = ed25519.PublicKey(b)
        }
    }

    // 2. Canonicalize (RFC 8785 stub — sorted keys, no whitespace)
    sig := att["signature"].(string)
    delete(att, "signature")
    keys := make([]string, 0, len(att))
    for k := range att { keys = append(keys, k) }
    sort.Strings(keys)
    canon := make(map[string]any, len(att))
    for _, k := range keys { canon[k] = att[k] }
    body, _ := json.Marshal(canon)

    sigBytes, _ := base64.URLEncoding.DecodeString(sig)
    if !ed25519.Verify(pub, body, sigBytes) {
        return fmt.Errorf("REJECT — invalid signature")
    }
    return nil
}

Endpoint .well-known/inopay

Expose les clés publiques courantes (avec key_id), la version du protocole, les capacités et les contacts. Mise à jour signalée par rotation explicite.

Exemple de réponse
# GET https://www.getinopay.com/.well-known/inopay
{
  "version": "1.0",
  "issuer": "inopay",
  "protocol": "kyc-attestation/v1",
  "keys": [
    {
      "key_id":      "staging-2026-04",
      "algorithm":   "Ed25519",
      "public_key":  "WkZmS2pYNXRxQU5wYTU0X3FHc...base64url",
      "valid_from":  "2026-04-01T00:00:00Z",
      "valid_until": "2026-10-01T00:00:00Z"
    },
    {
      "key_id":      "staging-2026-10",
      "algorithm":   "Ed25519",
      "public_key":  "M0FoNDF6dG9LZjN1WXFwT09...base64url",
      "valid_from":  "2026-10-01T00:00:00Z",
      "valid_until": "2027-04-01T00:00:00Z"
    }
  ],
  "crl_endpoint": "https://api.getinopay.com/v1/kyc/crl",
  "contact": "security@getinopay.com"
}

Niveaux KYC

Trois niveaux progressifs alignés sur les exigences UEMOA / CEMAC. Les niveaux supérieurs incluent toutes les vérifications des niveaux inférieurs.

NiveauExigencesPlafond / horizon
KYC1Pièce d'identité OCR + selfieComptes lite, 100 000 FCFA / mois
KYC2KYC1 + justificatif de domicile + vidéo selfie livenessInvestisseur particulier, 5 M FCFA / mois
KYC3KYC2 + déclaration source des fonds + vérification PEPInvestisseur qualifié, illimité

Politique de révocation

Une attestation est révocable à tout moment. La CRL (Certificate Revocation List) est publiée signée et synchronisable. Intervalle de check recommandé : 15 min. Période de grâce post-révocation : 24 h.

Reconnaissance régulateur

La Doctrine CREPMF 2026 reconnaît la portabilité du KYC signé cryptographiquement comme équivalent à un onboarding traditionnel. Voir la page Conformité pour le détail réglementaire.

Voir /compliance#crepmf

Spécification KYC portable Ed25519

Une attestation KYC Inopay est un objet JSON signé Ed25519. Elle est vérifiable hors-ligne par n'importe quel intégrateur, sans appel à un service tiers. Reconnue par la Doctrine CREPMF 2026 comme équivalent à un onboarding traditionnel.

Retour aux développeurs
Voir /compliance#crepmf