348 lines
10 KiB
Go
348 lines
10 KiB
Go
// P0/PRF Registration API
|
|
// Stores wrapped L3 credentials in admin DB for POP distribution
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// CredentialRegisterRequest - what the client sends when enrolling a device
|
|
type CredentialRegisterRequest struct {
|
|
// Identity
|
|
CustomerID string `json:"customer_id"` // Who owns this credential
|
|
VaultL0 string `json:"vault_l0"` // Which vault this opens
|
|
|
|
// WebAuthn data
|
|
CredentialID string `json:"credential_id"` // base64 encoded WebAuthn credential ID
|
|
PublicKey string `json:"public_key"` // base64 encoded public key (for signature verification)
|
|
|
|
// PRF/P0 data
|
|
P0 string `json:"p0"` // First 4 bytes of PRF, hex encoded (lookup key)
|
|
PRFSalt string `json:"prf_salt"` // Salt used for PRF (should be "Clavitor")
|
|
|
|
// Wrapped L3 (the actual vault key, encrypted with PRF)
|
|
WrappedL3 string `json:"wrapped_l3"` // base64 encoded encrypted L3
|
|
|
|
// Metadata
|
|
EnrolledBy string `json:"enrolled_by"` // Human ID who authorized this (if adding 2nd device)
|
|
DeviceName string `json:"device_name"` // "YubiKey 5", "iPhone Touch ID", etc.
|
|
DeviceType string `json:"device_type"` // "cross-platform", "platform", "hybrid"
|
|
}
|
|
|
|
// CredentialRegisterResponse
|
|
type CredentialRegisterResponse struct {
|
|
Success bool `json:"success"`
|
|
CredentialID int64 `json:"credential_id,omitempty"` // Our internal ID
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// RegisterCredential stores a new P0→WL3 mapping
|
|
func handleRegisterCredential(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req CredentialRegisterRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
respondJSON(w, CredentialRegisterResponse{Success: false, Error: "invalid json: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.CustomerID == "" || req.VaultL0 == "" || req.P0 == "" || req.WrappedL3 == "" {
|
|
respondJSON(w, CredentialRegisterResponse{Success: false, Error: "missing required fields"})
|
|
return
|
|
}
|
|
|
|
// Verify customer exists
|
|
var customerExists bool
|
|
err := db.QueryRow("SELECT 1 FROM customers WHERE id = ?", req.CustomerID).Scan(&customerExists)
|
|
if err != nil {
|
|
respondJSON(w, CredentialRegisterResponse{Success: false, Error: "customer not found"})
|
|
return
|
|
}
|
|
|
|
// Check for P0 collision (should be rare but possible)
|
|
var existingL0 string
|
|
err = db.QueryRow("SELECT l0 FROM credentials WHERE p0 = ?", req.P0).Scan(&existingL0)
|
|
if err == nil && existingL0 != req.VaultL0 {
|
|
// P0 collision with different vault - this is a problem
|
|
respondJSON(w, CredentialRegisterResponse{Success: false, Error: "P0 collision detected - different vault"})
|
|
return
|
|
}
|
|
|
|
// Generate WL3 storage path
|
|
// Format: {first_2_chars_of_p0}/{full_p0}/{timestamp}_{random}.cla
|
|
wl3Path := generateWL3Path(req.P0)
|
|
|
|
// Generate our internal credential ID
|
|
credID := generateInternalID()
|
|
|
|
// Store in DB
|
|
_, err = db.Exec(`
|
|
INSERT INTO credentials (
|
|
id, customer_id, p0, l0, credential_id, public_key,
|
|
wl3_path, enrolled_by, device_name, device_type,
|
|
created_at, last_used_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
credID,
|
|
req.CustomerID,
|
|
req.P0,
|
|
req.VaultL0,
|
|
req.CredentialID, // Store original WebAuthn credential ID
|
|
req.PublicKey,
|
|
wl3Path,
|
|
req.EnrolledBy,
|
|
req.DeviceName,
|
|
req.DeviceType,
|
|
time.Now().Unix(),
|
|
time.Now().Unix(),
|
|
)
|
|
|
|
if err != nil {
|
|
respondJSON(w, CredentialRegisterResponse{Success: false, Error: "database error: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Store the actual wrapped L3 content
|
|
// In production this would write to the WORM filesystem
|
|
// For now we store it in a separate table or as a blob
|
|
wrappedL3Bytes, _ := base64.StdEncoding.DecodeString(req.WrappedL3)
|
|
err = storeWL3Content(wl3Path, wrappedL3Bytes)
|
|
if err != nil {
|
|
// Rollback credential record
|
|
db.Exec("DELETE FROM credentials WHERE id = ?", credID)
|
|
respondJSON(w, CredentialRegisterResponse{Success: false, Error: "failed to store WL3: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Log the event
|
|
logEvent("credential_registered", req.CustomerID, req.VaultL0, map[string]interface{}{
|
|
"credential_id": credID,
|
|
"p0": req.P0,
|
|
"device_name": req.DeviceName,
|
|
})
|
|
|
|
respondJSON(w, CredentialRegisterResponse{Success: true, CredentialID: credID})
|
|
}
|
|
|
|
// ListCredentialsForCustomer - returns all credentials for a customer
|
|
// Used by POPs to pull WL3s for their assigned customers
|
|
func handleListCredentials(w http.ResponseWriter, r *http.Request) {
|
|
customerID := r.URL.Query().Get("customer_id")
|
|
if customerID == "" {
|
|
http.Error(w, "customer_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// TODO: Authenticate POP - must have valid agent token
|
|
// For now open for Q&D
|
|
|
|
rows, err := db.Query(`
|
|
SELECT id, p0, l0, wl3_path, device_name, device_type, created_at
|
|
FROM credentials
|
|
WHERE customer_id = ?
|
|
ORDER BY created_at DESC
|
|
`, customerID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var creds []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, p0, l0, path, deviceName, deviceType string
|
|
var createdAt int64
|
|
rows.Scan(&id, &p0, &l0, &path, &deviceName, &deviceType, &createdAt)
|
|
creds = append(creds, map[string]interface{}{
|
|
"id": id,
|
|
"p0": p0,
|
|
"l0": l0,
|
|
"wl3_path": path,
|
|
"device_name": deviceName,
|
|
"device_type": deviceType,
|
|
"created_at": createdAt,
|
|
})
|
|
}
|
|
|
|
respondJSON(w, creds)
|
|
}
|
|
|
|
// GetWL3Content - returns the actual wrapped L3 bytes
|
|
// POPs call this to download WL3 for local storage
|
|
func handleGetWL3(w http.ResponseWriter, r *http.Request) {
|
|
p0 := r.URL.Query().Get("p0")
|
|
if p0 == "" {
|
|
http.Error(w, "p0 required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// TODO: Authenticate POP with super duper secure auth
|
|
// For now just return the content
|
|
|
|
// Find the WL3 path
|
|
var wl3Path string
|
|
err := db.QueryRow("SELECT wl3_path FROM credentials WHERE p0 = ? ORDER BY created_at DESC LIMIT 1", p0).Scan(&wl3Path)
|
|
if err != nil {
|
|
http.Error(w, "credential not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Read and return
|
|
content, err := readWL3Content(wl3Path)
|
|
if err != nil {
|
|
http.Error(w, "failed to read WL3: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Write(content)
|
|
}
|
|
|
|
// SyncStatus - returns credentials that need syncing to POPs
|
|
// POPs poll this to know what to download
|
|
func handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
|
// Query params
|
|
popID := r.URL.Query().Get("pop_id")
|
|
lastSync := r.URL.Query().Get("last_sync")
|
|
|
|
if popID == "" {
|
|
http.Error(w, "pop_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// TODO: Authenticate POP
|
|
|
|
var lastSyncTime int64
|
|
if lastSync != "" {
|
|
lastSyncTime, _ = strconv.ParseInt(lastSync, 10, 64)
|
|
}
|
|
|
|
// Return credentials created since last sync
|
|
rows, err := db.Query(`
|
|
SELECT id, customer_id, p0, l0, wl3_path, created_at
|
|
FROM credentials
|
|
WHERE created_at > ?
|
|
ORDER BY created_at ASC
|
|
LIMIT 1000
|
|
`, lastSyncTime)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var toSync []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, customerID, p0, l0, path string
|
|
var createdAt int64
|
|
rows.Scan(&id, &customerID, &p0, &l0, &path, &createdAt)
|
|
toSync = append(toSync, map[string]interface{}{
|
|
"id": id,
|
|
"customer_id": customerID,
|
|
"p0": p0,
|
|
"l0": l0,
|
|
"wl3_path": path,
|
|
"created_at": createdAt,
|
|
})
|
|
}
|
|
|
|
// Update POP sync state
|
|
_, _ = db.Exec(`
|
|
INSERT INTO pop_sync_state (pop_id, last_sync_at, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(pop_id) DO UPDATE SET
|
|
last_sync_at = excluded.last_sync_at,
|
|
updated_at = excluded.updated_at
|
|
`, popID, time.Now().Unix(), time.Now().Unix())
|
|
|
|
respondJSON(w, map[string]interface{}{
|
|
"credentials": toSync,
|
|
"server_time": time.Now().Unix(),
|
|
})
|
|
}
|
|
|
|
// TODO: SUPER DUPER SECURE POP AUTHENTICATION
|
|
// This needs:
|
|
// 1. mTLS - POP presents certificate signed by our CA
|
|
// 2. Signed requests - each request signed with POP's private key
|
|
// 3. Replay protection - nonce + timestamp
|
|
// 4. IP allowlist - only known POP IPs
|
|
// 5. Rate limiting per POP
|
|
// 6. Audit logging of all POP access
|
|
|
|
func handlePOPAuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// TODO: Implement super duper secure auth
|
|
// For now just pass through for Q&D
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func generateWL3Path(p0 string) string {
|
|
// P0 is hex, like "a1b2c3d4"
|
|
// Path: a1/a1b2c3d4/20250405_7a8f9e2b.cla
|
|
if len(p0) < 4 {
|
|
p0 = fmt.Sprintf("%08s", p0) // Pad
|
|
}
|
|
|
|
shard := p0[:2] // First 2 chars for sharding
|
|
now := time.Now()
|
|
random := generateRandomHex(8)
|
|
|
|
return fmt.Sprintf("%s/%s/%04d%02d%02d_%s.cla",
|
|
shard, p0, now.Year(), now.Month(), now.Day(), random)
|
|
}
|
|
|
|
func generateInternalID() int64 {
|
|
// Use timestamp + random for now
|
|
// In production use lib.NewID()
|
|
return time.Now().UnixNano()
|
|
}
|
|
|
|
func generateRandomHex(n int) string {
|
|
bytes := make([]byte, n/2)
|
|
// In real code: crypto/rand
|
|
for i := range bytes {
|
|
bytes[i] = byte(i) // Fake
|
|
}
|
|
return fmt.Sprintf("%x", bytes)
|
|
}
|
|
|
|
func storeWL3Content(path string, content []byte) error {
|
|
// TODO: Write to WORM filesystem
|
|
// For now store in a simple table
|
|
_, err := db.Exec(`
|
|
INSERT OR REPLACE INTO wl3_storage (path, content, created_at)
|
|
VALUES (?, ?, ?)
|
|
`, path, content, time.Now().Unix())
|
|
return err
|
|
}
|
|
|
|
func readWL3Content(path string) ([]byte, error) {
|
|
// TODO: Read from WORM filesystem
|
|
// For now read from table
|
|
var content []byte
|
|
err := db.QueryRow("SELECT content FROM wl3_storage WHERE path = ?", path).Scan(&content)
|
|
return content, err
|
|
}
|
|
|
|
func logEvent(eventType, customerID, vaultL0 string, details map[string]interface{}) {
|
|
detailsJSON, _ := json.Marshal(details)
|
|
db.Exec(`
|
|
INSERT INTO events (occurred_at, event_type, entity_type, entity_id, customer_id, details, source)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, time.Now().Unix(), eventType, "credential", "", customerID, string(detailsJSON), "api")
|
|
}
|