Boni KYC Lab 4 · Go backend ~8 min Step 1
Lab 4 · Go backend

Repositories with sqlc; the OMANG hash

generate type-safe Go from SQL with sqlc, wrap it in a citizens

repository, and implement the blind-index helper that lets you check "is this OMANG/phone already registered?" without storing the raw value.

Concepts — sqlc codegen, the repository pattern in Go (interface in the consumer package), HMAC blind indexes, separating "store" from "domain".

Note

Idiom note: Go's repository pattern is lighter than what you may know from other ecosystems. Define the interface where it's used (the service), and let the concrete struct live in store. No DI container needed.

Step 2 · Build

Build

sqlc.yaml:

sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    schema: "migrations"
    queries: "query"
    gen:
      go:
        package: "sqlcgen"
        out: "internal/store/sqlcgen"
        sql_package: "pgx/v5"
        emit_pointers_for_null_types: true
Step 3 · Build

query/citizens.sql

query/citizens.sql:

query/citizens.sql
-- name: CreateCitizen :one
INSERT INTO citizens (phone_blind_idx, phone_enc, password_hash, dek_wrapped, kek_version)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, status, created_at;

-- name: GetCitizenByPhoneIdx :one
SELECT * FROM citizens WHERE phone_blind_idx = $1;

-- name: CitizenExistsByOmangIdx :one
SELECT EXISTS(SELECT 1 FROM citizens WHERE omang_blind_idx = $1);
-- ⟵ YOU: add SetOmangIdx (used by the verification pipeline in lab 10)
Step 4 · Build

internal/crypto/blindindex.go

internal/crypto/blindindex.go:

internal/crypto/blindindex.go
package crypto

import (
	"crypto/hmac"
	"crypto/sha256"
)

// BlindIndex is a deterministic keyed hash: same input + pepper → same output,
// but the input can't be recovered. Lets us enforce UNIQUE on a value we never
// store in plaintext. The pepper is a server secret (loaded in lab 7), NOT in DB.
func BlindIndex(pepper []byte, value string) []byte {
	mac := hmac.New(sha256.New, pepper)
	mac.Write([]byte(value)) // ⟵ YOU: should you normalize `value` first (trim, lowercase, E.164)? decide & document
	return mac.Sum(nil)
}
Step 5 · Build

internal/store/citizens.go

internal/store/citizens.go — wrap sqlcgen:

internal/store/citizens.go
type CitizenRepo struct{ q *sqlcgen.Queries }

func (r *CitizenRepo) Create(ctx context.Context, in NewCitizen) (Citizen, error) {
	// ⟵ YOU: map domain struct → sqlcgen params → domain result.
	// Keep sqlcgen types from leaking out of the store package.
}
Step 6 · Research checkpoints

Research checkpoints

Install + run sqlc

; understand :one :many :exec annotations and how nullable columns map to Go (pointers vs pgtype).

Why HMAC, not plain SHA-256, for the blind index?

(Search "blind index HMAC vs hash"; the pepper is the point — defends against offline dictionary attacks on a stolen DB. Botswana phone space is tiny.)

Normalization

if two users enter the same phone differently (+267... vs 267...), do their blind indexes match? They must, or UNIQUE is useless. Decide the canonical form and where you enforce it.

Repository interface placement

read a short piece on "accept interfaces, return structs" in Go.

Step 7 · Verify

Verify

  • sqlc generate produces compiling Go; make tidy clean.
  • A unit test: BlindIndex(pepper, "+26771234567") is stable across runs

and differs for a different pepper.

  • Creating two citizens with the same normalized phone → the second fails

on the UNIQUE constraint (caught and surfaced as a clean 409, not a 500).

  • grep -r confirms no raw phone/OMANG string is ever passed to an INSERT

except as *_enc (encrypted, lab 7) or *_blind_idx.