Documentation
¶
Overview ¶
Package vault stores PII↔token mappings — the most sensitive component of any PORTUNUS deployment (ADR-0006).
Tokens are random (never derived from the value — PCI's strongest class; FPE was rejected, see ADR-0006). Deduplication within a scope uses an HMAC-SHA256 fingerprint of (tenant key, conversation ID, entity type, NFC-normalized value) — the Basis Theory pattern: deterministic behavior inside a conversation, unlinkable tokens across conversations. Per-tenant deterministic scope exists only as an explicit, documented opt-in.
Storage is field-level envelope encryption: values sealed with AES-256-GCM under a per-conversation DEK; DEKs wrapped under a KEK via the KEKProvider (Wrap/Unwrap) seam — local in-process for v0.1, KMS-ready (ADR-0017 §6). The KEK is derived from one operator root with the fingerprint key via HKDF (domain-separated; ADR-0017 §4). Expiry (default 72h after last activity, max 30d) deletes rows AND destroys the conversation DEK — crypto-shredding, which is also the GDPR Art. 17 erasure path (key destruction is the guarantee; ADR-0017 §3).
Detokenization is a privileged operation (PCI doctrine): deny-all outside the response-rewrite path, every request audited (caller, token, conversation, result), rate-alarmed against bulk detokenization.
Failure rules (ADR-0005 §5): tokenization errors fail closed — the prompt never leaves with raw PII. Key loss degrades safely: responses deliver with placeholders intact plus an error flag; values are never fabricated.
Non-goals: no PII detection (internal/policy/piidetect decides WHAT to tokenize; the vault stores and restores), no SQL specifics beyond the interfaces internal/store provides, no marker-format knowledge beyond the token ID contract.
Example (ValueSeal) ¶
Example_valueSeal shows sealing and opening a PII value under a conversation DEK, with AAD binding the ciphertext to (conversation, entity type).
dek, _ := newDEK()
aad := frame([]byte("portunus/vault/value-seal/v1"), []byte("conv-1"), []byte("EMAIL"))
nonce, ct, _ := sealValue(dek, []byte("ali@example.com"), aad)
plain, _ := openValue(dek, nonce, ct, aad)
fmt.Println(string(plain))
Output: ali@example.com
Index ¶
- type Cipher
- func (c *Cipher) Fingerprint(internalConvID, entityType string, nfcValue []byte) []byte
- func (c *Cipher) NewWrappedDEK(ctx context.Context, internalConvID string) (dek, wrapped []byte, kekID string, err error)
- func (c *Cipher) Open(dek []byte, internalConvID, entityType string, nonce, ciphertext []byte) (value []byte, err error)
- func (c *Cipher) Seal(dek []byte, internalConvID, entityType string, value []byte) (nonce, ciphertext []byte, err error)
- func (c *Cipher) UnwrapDEK(ctx context.Context, kekID, internalConvID string, wrapped []byte) (dek []byte, err error)
- func (c *Cipher) Zeroize()
- type KEKProvider
- type LocalKEKProvider
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Cipher ¶
type Cipher struct {
// contains filtered or unexported fields
}
Cipher is the vault's exported crypto facade for the tokenization policy (ADR-0006 §3, ADR-0017). It holds the KEK provider and the derived fingerprint key, and exposes conversation-scoped wrap / seal / fingerprint with the AAD framing handled INTERNALLY — the frozen format never leaks to the policy layer (which knows nothing about crypto, vault doc.go non-goals).
internalConvID MUST be the random, never-recycled internal conversation id (ADR-0017 §1): it is the HKDF-derive input for the wrap subkey, so reuse with a different DEK is a catastrophic GCM break.
Example ¶
ExampleCipher shows the conversation-scoped envelope flow the tokenization policy uses: derive keys from the operator root, mint a wrapped DEK for a conversation, seal a value, then unwrap + open to restore it.
c, _ := NewCipher(bytes.Repeat([]byte{0x42}, 32), "kek-1")
ctx := context.Background()
dek, wrapped, kekID, _ := c.NewWrappedDEK(ctx, "INT_A") // internal id MUST be random
nonce, ct, _ := c.Seal(dek, "INT_A", "EMAIL", []byte("ali@example.com"))
// Later (e.g. detokenization): recover the DEK and open the value.
dek2, _ := c.UnwrapDEK(ctx, kekID, "INT_A", wrapped)
value, _ := c.Open(dek2, "INT_A", "EMAIL", nonce, ct)
fmt.Println(string(value))
Output: ali@example.com
func NewCipher ¶
NewCipher derives the KEK and fingerprint key from the operator root secret (ADR-0017 §4) and builds a local in-process KEK provider under activeKEKID. (A KMS-backed provider is a later swap behind the same Cipher API.)
func (*Cipher) Fingerprint ¶
Fingerprint computes the conversation-scoped dedup fingerprint for a value (which MUST already be NFC-normalized).
func (*Cipher) NewWrappedDEK ¶
func (c *Cipher) NewWrappedDEK(ctx context.Context, internalConvID string) (dek, wrapped []byte, kekID string, err error)
NewWrappedDEK mints a fresh per-conversation DEK and returns it both in the clear (for immediate use, then clear()) and wrapped under the active KEK, with the kek id the caller must persist alongside the wrapped bytes.
func (*Cipher) Open ¶
func (c *Cipher) Open(dek []byte, internalConvID, entityType string, nonce, ciphertext []byte) (value []byte, err error)
Open decrypts a sealed PII value; it fails if dek, nonce, conversation, or entity type do not match what Seal used.
func (*Cipher) Seal ¶
func (c *Cipher) Seal(dek []byte, internalConvID, entityType string, value []byte) (nonce, ciphertext []byte, err error)
Seal encrypts a PII value under the conversation DEK, binding the ciphertext to (conversation, entity type).
func (*Cipher) UnwrapDEK ¶
func (c *Cipher) UnwrapDEK(ctx context.Context, kekID, internalConvID string, wrapped []byte) (dek []byte, err error)
UnwrapDEK recovers a conversation DEK from its wrapped form (the kek id and internal conversation id must match what NewWrappedDEK used — AAD binding).
v0.1: NewCipher's provider holds only the ACTIVE kek, so kekID must equal the active id; unwrapping a DEK wrapped under a retired kek (after rotation) will fail until the provider is constructed with the historical keks (rotation, ADR-0017 §6, a later bite).
func (*Cipher) Zeroize ¶
func (c *Cipher) Zeroize()
Zeroize best-effort wipes the long-lived fingerprint key; call at teardown. The KEK is wiped in NewCipher once the provider has copied it, but fpKey is the working key — live for the Cipher's lifetime — so it is wiped here instead. Best effort per ADR-0017 §7 (Go's GC may have copied it; mlock is deferred). After Zeroize the Cipher must not be used (Fingerprint would compute under a zero key).
type KEKProvider ¶
type KEKProvider interface {
// ActiveKEKID is the id for new wraps and the rotation target.
ActiveKEKID(ctx context.Context) (string, error)
// Wrap encrypts dek under the KEK identified by kekID, binding the
// ciphertext to aad. Old kekIDs must keep resolving until every DEK that
// references them has been re-wrapped.
//
// WARNING (ADR-0017 §1): the local provider is derive-then-seal with a
// FIXED nonce — the subkey is HKDF(KEK, info=aad), so Wrap is deterministic
// in (kekID, aad, dek). Callers MUST NEVER call Wrap with the same aad but
// a DIFFERENT dek: that reuses (subkey, nonce) and is a catastrophic GCM
// break (keystream reuse + GHASH key recovery). Because aad carries the
// conversation_id, this means a conversation_id must never be reused with a
// different DEK — the vault enforces this with random 128-bit ids and a
// one-DEK-per-conversation primary key.
Wrap(ctx context.Context, kekID string, dek, aad []byte) (wrapped []byte, err error)
// Unwrap reverses Wrap; it fails if kekID, the wrapped bytes, or aad do
// not match.
Unwrap(ctx context.Context, kekID string, wrapped, aad []byte) (dek []byte, err error)
}
KEKProvider wraps and unwraps per-conversation DEKs (ADR-0017 §6). It never exports raw key material — the only seam that survives the future KMS transition (a KMS does the wrap/unwrap itself and never releases the KEK).
type LocalKEKProvider ¶
type LocalKEKProvider struct {
// contains filtered or unexported fields
}
LocalKEKProvider keeps KEKs in process (v0.1 file/env/keychain source). It wraps DEKs with derive-then-seal (ADR-0017 §1): a single-use subkey HKDF-Expand(KEK, info=aad) then fixed-nonce AES-256-GCM — no nonce ceiling.
Example ¶
ExampleLocalKEKProvider shows the canonical envelope flow: derive keys from a root secret, mint a per-conversation DEK, wrap it under the KEK, and unwrap it back. AAD binds the wrapped DEK to its conversation (ADR-0017 §1/§6).
root := bytes.Repeat([]byte{0x01}, 32) // operator root secret (file/env/keychain)
kek, _, _ := deriveKeys(root)
p, _ := NewLocalKEKProvider("kek-1", map[string][]byte{"kek-1": kek})
dek, _ := newDEK()
aad := frame([]byte("portunus/vault/dek-wrap/v1"), []byte("kek-1"), []byte("conv-1"))
wrapped, _ := p.Wrap(context.Background(), "kek-1", dek, aad)
got, _ := p.Unwrap(context.Background(), "kek-1", wrapped, aad)
fmt.Println(bytes.Equal(got, dek))
Output: true
func NewLocalKEKProvider ¶
func NewLocalKEKProvider(activeID string, keks map[string][]byte) (*LocalKEKProvider, error)
NewLocalKEKProvider builds a provider over the given kekID→key map with the named active id. It copies the key material; every key must be 32 bytes and the active id must be present.
func (*LocalKEKProvider) ActiveKEKID ¶
func (p *LocalKEKProvider) ActiveKEKID(ctx context.Context) (string, error)
ActiveKEKID implements KEKProvider.