M-7: every grant and access token lives in the vault
The manager hands what the vendor answers to licence-put, the grant first and then the access, at the licence's next generation, and reads a grant with get for one act; it keeps no token at rest (ADR 0550 §3). The sealing at rest goes; opening stays for the carry-over. Builds: design 39 bus-and-switch §15, §16, §20, §45, B15 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
@@ -5,7 +5,6 @@ package main
|
|||||||
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Crypt seals and opens what the store keeps.
|
// Crypt opens what today's database keeps at rest.
|
||||||
type Crypt struct{ key []byte }
|
type Crypt struct{ key []byte }
|
||||||
|
|
||||||
// NewCrypt is the store's cipher from the vault's secret.
|
// NewCrypt is the store's cipher from the vault's secret.
|
||||||
@@ -35,16 +34,8 @@ func CryptFromFile(path string) (*Crypt, error) {
|
|||||||
return NewCrypt(string(raw))
|
return NewCrypt(string(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
|
// Open decrypts a grant today's database keeps at rest, `v1.<iv>.<ciphertext and tag>`: the carry-over's alone, since
|
||||||
func (c *Crypt) Seal(plaintext string) string {
|
// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15).
|
||||||
gcm, _ := newGCM(c.key)
|
|
||||||
iv := make([]byte, 12)
|
|
||||||
_, _ = rand.Read(iv)
|
|
||||||
b64 := base64.StdEncoding.EncodeToString
|
|
||||||
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open decrypts what Seal made with the same key.
|
|
||||||
func (c *Crypt) Open(sealed string) (string, error) {
|
func (c *Crypt) Open(sealed string) (string, error) {
|
||||||
parts := strings.Split(sealed, ".")
|
parts := strings.Split(sealed, ".")
|
||||||
if len(parts) != 3 || parts[0] != "v1" {
|
if len(parts) != 3 || parts[0] != "v1" {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func manager() (*Manager, error) {
|
|||||||
Store: store,
|
Store: store,
|
||||||
Vendor: LiveVendor(),
|
Vendor: LiveVendor(),
|
||||||
Crypt: crypt,
|
Crypt: crypt,
|
||||||
|
Grants: VaultGrants{Keys: keys},
|
||||||
Keys: keys,
|
Keys: keys,
|
||||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
var a GrantAnswer
|
var a GrantAnswer
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ type GrantAnswer struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
Store Store
|
Store Store
|
||||||
Vendor Vendor
|
Vendor Vendor
|
||||||
|
// Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3).
|
||||||
Crypt *Crypt
|
Crypt *Crypt
|
||||||
|
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
|
||||||
|
Grants Grants
|
||||||
Keys KeyPair
|
Keys KeyPair
|
||||||
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
||||||
// is the node not answering; a nil Sealed is no login waiting.
|
// is the node not answering; a nil Sealed is no login waiting.
|
||||||
@@ -395,7 +398,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
||||||
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
||||||
if held != nil {
|
if held != nil {
|
||||||
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
|
l.Name, l.NotifiedAt, l.Generation = held.Name, held.NotifiedAt, held.Generation
|
||||||
if l.Email == "" {
|
if l.Email == "" {
|
||||||
l.Email = held.Email
|
l.Email = held.Email
|
||||||
}
|
}
|
||||||
@@ -403,7 +406,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
l.OrganizationUUID = held.OrganizationUUID
|
l.OrganizationUUID = held.OrganizationUUID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.keep(&l, r.Grant)
|
if err := m.keep(ctx, &l, r.Grant); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
l.AdoptedAt = max(now, c.ChangedAt)
|
l.AdoptedAt = max(now, c.ChangedAt)
|
||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -454,10 +459,16 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
return l.Name, nil
|
return l.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
|
// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
|
||||||
func (m *Manager) keep(l *Licence, g FullGrant) {
|
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
|
||||||
raw, _ := json.Marshal(g)
|
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
|
||||||
l.Sealed = m.Crypt.Seal(string(raw))
|
grant, _ := json.Marshal(g)
|
||||||
|
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||||
|
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||||
|
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||||
|
if err := m.handOver(ctx, l, string(grant), string(access)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
||||||
l.AccessExpiresAt = g.ExpiresAt
|
l.AccessExpiresAt = g.ExpiresAt
|
||||||
if g.RefreshTokenExpiresAt != nil {
|
if g.RefreshTokenExpiresAt != nil {
|
||||||
@@ -465,6 +476,29 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
|||||||
}
|
}
|
||||||
l.Failures = 0
|
l.Failures = 0
|
||||||
l.RotatedAt = m.Now().UnixMilli()
|
l.RotatedAt = m.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
|
||||||
|
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
|
||||||
|
generation := l.Generation + 1
|
||||||
|
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
|
||||||
|
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
|
||||||
|
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.Generation = generation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
|
||||||
|
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
|
||||||
|
var g FullGrant
|
||||||
|
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
|
||||||
|
if err != nil {
|
||||||
|
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
|
||||||
|
}
|
||||||
|
return g, json.Unmarshal([]byte(plain), &g)
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
||||||
@@ -488,7 +522,7 @@ func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
|||||||
|
|
||||||
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
||||||
func Due(l Licence, now time.Time, s Settings) bool {
|
func Due(l Licence, now time.Time, s Settings) bool {
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ms := now.UnixMilli()
|
ms := now.UnixMilli()
|
||||||
@@ -518,22 +552,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
|||||||
if l == nil {
|
if l == nil {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
||||||
}
|
}
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
||||||
}
|
}
|
||||||
now := m.Now()
|
now := m.Now()
|
||||||
if !force && !Due(*l, now, m.Settings) {
|
if !force && !Due(*l, now, m.Settings) {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
g, err := m.grantOf(ctx, *l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var g FullGrant
|
|
||||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r := m.Vendor.Refresh(ctx, g)
|
r := m.Vendor.Refresh(ctx, g)
|
||||||
|
g = FullGrant{}
|
||||||
if !r.OK {
|
if !r.OK {
|
||||||
l.Failures++
|
l.Failures++
|
||||||
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
||||||
@@ -544,7 +575,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
|||||||
}
|
}
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
||||||
}
|
}
|
||||||
m.keep(l, r.Grant)
|
if err := m.keep(ctx, l, r.Grant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if l.RefreshExpiresAt != 0 {
|
if l.RefreshExpiresAt != 0 {
|
||||||
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
||||||
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
||||||
@@ -611,16 +644,15 @@ func (m *Manager) ReadUsage(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
g, err := m.grantOf(ctx, l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var g FullGrant
|
|
||||||
_ = json.Unmarshal([]byte(plain), &g)
|
|
||||||
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
||||||
|
g = FullGrant{}
|
||||||
if err != nil || raw == nil {
|
if err != nil || raw == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -789,7 +821,13 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
|||||||
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
|
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
|
||||||
return nil, fmt.Errorf("%s is refused: %s", name, why)
|
return nil, fmt.Errorf("%s is refused: %s", name, why)
|
||||||
}
|
}
|
||||||
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
|
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli()}
|
||||||
|
if held != nil {
|
||||||
|
l.Generation = held.Generation
|
||||||
|
}
|
||||||
|
if err := m.handOver(ctx, &l, key, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,42 @@ func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
|||||||
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
|
||||||
|
type memGrants struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
held map[string]heldGrant
|
||||||
|
puts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type heldGrant struct {
|
||||||
|
generation int64
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
name := SecretName(key, part)
|
||||||
|
if h, ok := g.held[name]; ok && h.generation >= generation {
|
||||||
|
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
|
||||||
|
}
|
||||||
|
g.held[name] = heldGrant{generation, value}
|
||||||
|
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
h, ok := g.held[SecretName(key, part)]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
|
||||||
|
}
|
||||||
|
return h.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
type miniMesh struct {
|
type miniMesh struct {
|
||||||
|
grants *memGrants
|
||||||
m *Manager
|
m *Manager
|
||||||
store *MemoryStore
|
store *MemoryStore
|
||||||
vendor *stubVendor
|
vendor *stubVendor
|
||||||
@@ -61,14 +96,15 @@ type miniMesh struct {
|
|||||||
|
|
||||||
func newMesh(t *testing.T) *miniMesh {
|
func newMesh(t *testing.T) *miniMesh {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
|
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0,
|
||||||
|
grants: &memGrants{held: map[string]heldGrant{}}}
|
||||||
now := func() time.Time { return mm.now }
|
now := func() time.Time { return mm.now }
|
||||||
mm.store = NewMemoryStore(now)
|
mm.store = NewMemoryStore(now)
|
||||||
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||||
crypt, _ := NewCrypt("a key the vault made")
|
crypt, _ := NewCrypt("a key the vault made")
|
||||||
mm.keys, _ = GenerateKeyPair()
|
mm.keys, _ = GenerateKeyPair()
|
||||||
mm.m = &Manager{
|
mm.m = &Manager{
|
||||||
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
|
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys,
|
||||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
if mm.askDown {
|
if mm.askDown {
|
||||||
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
||||||
@@ -304,8 +340,8 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
row, _ := mm.store.Licence(ctx, licence1)
|
row, _ := mm.store.Licence(ctx, licence1)
|
||||||
if strings.Contains(row.Sealed, "rt-") {
|
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
|
||||||
t.Fatal("the grant is stored in the clear")
|
t.Fatalf("the licence keeps a token: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,9 +456,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
|||||||
if err != nil || r["adopted"] != true {
|
if err != nil || r["adopted"] != true {
|
||||||
t.Fatalf("%v %v", r, err)
|
t.Fatalf("%v %v", r, err)
|
||||||
}
|
}
|
||||||
l, _ := mm.store.Licence(ctx, "api")
|
for _, part := range []string{PartGrant, PartAccess} {
|
||||||
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
||||||
t.Fatalf("stored %q", plain)
|
t.Fatalf("the vault holds %q as the %s", plain, part)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other, _ := GenerateKeyPair()
|
other, _ := GenerateKeyPair()
|
||||||
wrong, _ := Seal("sk", other.PublicKey)
|
wrong, _ := Seal("sk", other.PublicKey)
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''
|
|||||||
|
|
||||||
func scanLicence(row pgx.Row) (*Licence, error) {
|
func scanLicence(row pgx.Row) (*Licence, error) {
|
||||||
var l Licence
|
var l Licence
|
||||||
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
var sealed string // the grant at rest, read by the carry-over alone (design 39 bus-and-switch §12)
|
||||||
|
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &sealed, &l.RefreshFingerprint,
|
||||||
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -150,15 +151,15 @@ func nullableInt(v int64) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
||||||
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
|
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, refresh_fingerprint,
|
||||||
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
||||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
||||||
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
|
organization_uuid = excluded.organization_uuid, refresh_fingerprint = excluded.refresh_fingerprint,
|
||||||
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
||||||
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
||||||
rotated_at = excluded.rotated_at`,
|
rotated_at = excluded.rotated_at`,
|
||||||
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
|
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID),
|
||||||
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
||||||
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ type Licence struct {
|
|||||||
AccountUUID string `json:"accountUuid,omitempty"`
|
AccountUUID string `json:"accountUuid,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||||
Sealed string `json:"-"` // the grant or the key, encrypted at rest
|
|
||||||
RefreshFingerprint string `json:"-"`
|
RefreshFingerprint string `json:"-"`
|
||||||
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
||||||
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
||||||
@@ -27,6 +26,8 @@ type Licence struct {
|
|||||||
NotifiedAt int64 `json:"-"`
|
NotifiedAt int64 `json:"-"`
|
||||||
AdoptedAt int64 `json:"adoptedAt"`
|
AdoptedAt int64 `json:"adoptedAt"`
|
||||||
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
||||||
|
// Generation is the generation of the licence's secrets in the vault (design 39 bus-and-switch §2, §15).
|
||||||
|
Generation int64 `json:"generation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
|
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -33,7 +35,7 @@ func TestTheStoreOnPostgres(t *testing.T) {
|
|||||||
t.Fatalf("migration %d: %v", i+1, err)
|
t.Fatalf("migration %d: %v", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
|
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org",
|
||||||
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
||||||
if err := s.SaveLicence(ctx, l); err != nil {
|
if err := s.SaveLicence(ctx, l); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -85,7 +87,7 @@ func TestTheStoreOnPostgres(t *testing.T) {
|
|||||||
t.Fatal("unbind")
|
t.Fatal("unbind")
|
||||||
}
|
}
|
||||||
all, _ := s.Licences(ctx)
|
all, _ := s.Licences(ctx)
|
||||||
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
|
if len(all) != 1 {
|
||||||
t.Fatalf("%v", all)
|
t.Fatalf("%v", all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
|||||||
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||||
a, _ := NewCrypt("one key")
|
a, _ := NewCrypt("one key")
|
||||||
b, _ := NewCrypt("another key")
|
b, _ := NewCrypt("another key")
|
||||||
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`)
|
||||||
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
||||||
if strings.Contains(sealed, "refreshToken") {
|
if strings.Contains(sealed, "refreshToken") {
|
||||||
t.Fatal("stored in the clear")
|
t.Fatal("stored in the clear")
|
||||||
@@ -124,3 +126,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
|||||||
t.Fatal("an empty key was accepted")
|
t.Fatal("an empty key was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sealAtRest seals a grant as today's database keeps it, `v1.<iv>.<ciphertext and tag>`: the rows the carry-over reads.
|
||||||
|
func sealAtRest(t *testing.T, c *Crypt, plaintext string) string {
|
||||||
|
t.Helper()
|
||||||
|
gcm, err := newGCM(c.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(iv)
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands
|
||||||
|
// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`,
|
||||||
|
// sealed to its own public key, for one act.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The parts of a licence the vault holds (§15).
|
||||||
|
const (
|
||||||
|
PartGrant = "grant"
|
||||||
|
PartAccess = "access"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grants is where a licence's two secrets are kept.
|
||||||
|
type Grants interface {
|
||||||
|
// Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused.
|
||||||
|
Put(ctx context.Context, key, part string, generation int64, value string) error
|
||||||
|
// Get reads one part of a licence from the vault.
|
||||||
|
Get(ctx context.Context, key, part string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretName is the name of one part of a licence in the vault.
|
||||||
|
func SecretName(key, part string) string { return "licence." + key + "." + part }
|
||||||
|
|
||||||
|
// VaultGrants is the vault on the bus, asked as the seat mesh-vault.
|
||||||
|
type VaultGrants struct{ Keys KeyPair }
|
||||||
|
|
||||||
|
// Put asks licence-put's two steps.
|
||||||
|
func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var opened struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
SealTo string `json:"seal_to"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &opened); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, err := base64.StdEncoding.DecodeString(opened.SealTo)
|
||||||
|
if err != nil || len(pub) != 32 || opened.Call == "" {
|
||||||
|
return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw)
|
||||||
|
}
|
||||||
|
var to [32]byte
|
||||||
|
copy(to[:], pub)
|
||||||
|
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call,
|
||||||
|
"sealed": base64.StdEncoding.EncodeToString(sealed)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asks get, sealed to the manager's public key, and opens the answer.
|
||||||
|
func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
Sealed SealedBox `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return Open(a.Sealed, v.Keys.PrivateKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first
|
||||||
|
// and then the access, at the next generation; the licence keeps no token once the call returns.
|
||||||
|
func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := KeyFor(licence1)
|
||||||
|
mm.grants.puts = nil
|
||||||
|
if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true {
|
||||||
|
t.Fatalf("%v %v", r, err)
|
||||||
|
}
|
||||||
|
want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"}
|
||||||
|
if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want)
|
||||||
|
}
|
||||||
|
if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) {
|
||||||
|
t.Fatalf("the vault holds the grant %s", g)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") {
|
||||||
|
t.Fatalf("the vault holds the access %s", a)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
raw, _ := json.Marshal(l)
|
||||||
|
for _, token := range []string{"rt-", "at-"} {
|
||||||
|
if strings.Contains(string(raw), token) {
|
||||||
|
t.Fatalf("the licence keeps %s: %s", token, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Generation != 2 {
|
||||||
|
t.Fatalf("the licence's generation is %d, want 2", l.Generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ go 1.26.0
|
|||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -15,7 +16,6 @@ require (
|
|||||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
github.com/nats-io/nats.go v1.54.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
|||||||
Reference in New Issue
Block a user