mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
deploy / build (push) Waiting to run
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]>
159 lines
4.2 KiB
Go
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)
|
|
}
|