Files
zeekayandHanzo Dev 6ca9ce9a19
deploy / build (push) Waiting to run
keys: derive every key with cek, and delete the ones that did not
base had its own key derivations and no dependency on hanzoai/cek, the
primitive that is meant to be the only one. Two HKDFs in the platform
plugin, two HMACs beside them for S3, and a two-level HMAC hierarchy in
the vault. None agreed with cek, and the comments describing them had
drifted off the code: org_db and platform.go both said HMAC where the
code ran HKDF, and the vault drew a KEK hierarchy in its package doc.

All of them are now cek.DeriveKey(master, namespace, subsystem):

	org database   org/{org}          "org"
	user database  org/{org}/{user}   "user"
	org objects    org/{org}          "s3"
	user objects   org/{org}/{user}   "s3"
	vault shard    org/{org}/{user}   "vault"
	shared vault   org/{org}/{vault}  "vault-shared"

A per-org and a per-user key are one derivation over two namespaces,
which is cek's argument: the key is bound to the name that renders the
path. namespace.Key refuses a user namespace, but that is about LAYOUT
and DeriveKey does not use it — a person's data is named org/{org}/{user}
here, which is what namespace.Of already builds, and it is what keeps
acme/alice and globex/alice on different keys. The vault reaches the same
namespace through OrgProject, because its ids are DIDs and a colon is not
legal in a segment; Sanitize is injective, so two DIDs cannot fold onto
one vault.

The vault's org KEK is gone. It was there to be rotatable without
re-encrypting user data, nothing ever rotated it, and the org is in the
namespace now — the property it was actually providing.

store/keyring.go keeps its own HKDF, and now says why. It WRAPS a random
age identity rather than deriving a key, so Rotate re-wraps one sidecar
instead of rewriting every byte of ciphertext, and its secret is a
per-org KMS KEK rather than one master. cek has no wrap by design. A
different shape, left alone rather than bent into one.

Tests now compare each derivation against cek.DeriveKey directly. A
comment claiming a derivation cannot fail when the derivation drifts,
which is exactly how the one deleted last time survived.

A master key of the wrong length is now an error instead of silently no
key, and the resolver's encKey, computed and never read, is gone.

deps: cek v0.2.3 and namespace v1.2.0, plus tasks v1.52.4 -> v1.52.9,
which is what moves hanzoai/sqlite to v0.5.0 — tasks below v1.52.9 still
calls the wrapped-DEK API that v0.5.0 removed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 19:56:10 -07:00

159 lines
4.2 KiB
Go

// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package vault
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/router"
)
// registerRoutes adds vault API routes.
// All routes require authentication — the user ID comes from the auth context.
func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
api := r.Group("/vault")
// Require auth for all vault routes.
api.BindFunc(func(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("auth required", nil)
}
return e.Next()
})
// GET /vault/status — shard status for current user
api.GET("/status", func(e *core.RequestEvent) error {
userID := e.Auth.Id
shard, err := p.GetShard(userID)
if err != nil {
return e.InternalServerError("", err)
}
return e.JSON(200, map[string]interface{}{
"user_id": userID,
"shard": shard.Path,
"encrypted": true,
"sync": p.config.SyncEnabled,
})
})
// POST /vault/put — store encrypted key-value
api.POST("/put", func(e *core.RequestEvent) error {
userID := e.Auth.Id
shard, err := p.GetShard(userID)
if err != nil {
return e.InternalServerError("", err)
}
var req struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := e.BindBody(&req); err != nil {
return e.BadRequestError("", nil)
}
encrypted, err := shard.Encrypt([]byte(req.Value))
if err != nil {
return e.InternalServerError("encrypt failed", err)
}
// In full implementation: write to user's SQLite shard + CRDT log
_ = encrypted
return e.JSON(200, map[string]string{
"status": "stored",
"key": req.Key,
"shard": shard.UserID,
})
})
// POST /vault/get — retrieve and decrypt
api.POST("/get", func(e *core.RequestEvent) error {
userID := e.Auth.Id
_, err := p.GetShard(userID)
if err != nil {
return e.InternalServerError("", err)
}
var req struct {
Key string `json:"key"`
}
if err := e.BindBody(&req); err != nil {
return e.BadRequestError("", nil)
}
// In full implementation: read from SQLite shard, decrypt with DEK
return e.JSON(200, map[string]string{
"key": req.Key,
"status": "decrypted",
})
})
// GET /vault/anchor — get current merkle root for chain anchoring
api.GET("/anchor", func(e *core.RequestEvent) error {
userID := e.Auth.Id
shard, err := p.GetShard(userID)
if err != nil {
return e.InternalServerError("", err)
}
// Compute merkle root of the shard state
hash := sha256.Sum256([]byte(shard.Path + ":" + shard.UserID))
return e.JSON(200, map[string]string{
"user_id": userID,
"merkle_root": hex.EncodeToString(hash[:]),
"chain": p.config.ChainRPC,
"status": "ready_to_anchor",
})
})
// GET /vault/keys — list user's encryption keys (metadata only, never DEK)
api.GET("/keys", func(e *core.RequestEvent) error {
userID := e.Auth.Id
return e.JSON(200, map[string]interface{}{
"user_id": userID,
"org_id": p.config.OrgID,
"key_hierarchy": map[string]string{
"master_key": "HSM / K-Chain ML-KEM (never exported)",
"user_dek": fmt.Sprintf("cek.DeriveKey(master, org/%s/%s, %q)", p.config.OrgID, userID, userSubsystem),
"encryption": "AES-256-GCM per entry",
},
})
})
// POST /vault/sync — trigger CRDT sync with peers
api.POST("/sync", func(e *core.RequestEvent) error {
if !p.config.SyncEnabled {
return e.JSON(200, map[string]string{"status": "sync_disabled"})
}
return e.JSON(200, map[string]string{"status": "sync_triggered"})
})
// POST /vault/export — export encrypted shard for backup/migration
api.POST("/export", func(e *core.RequestEvent) error {
userID := e.Auth.Id
shard, err := p.GetShard(userID)
if err != nil {
return e.InternalServerError("", err)
}
// In full implementation: read SQLite file, it's already encrypted
return e.JSON(200, map[string]interface{}{
"user_id": userID,
"path": shard.Path,
"format": "encrypted-sqlite",
"note": "file is AES-256-GCM encrypted with user DEK — safe to store anywhere",
})
})
}
func writeJSON(w interface{ Write([]byte) (int, error) }, v interface{}) {
json.NewEncoder(w).Encode(v)
}