This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const databaseCredentialKeyBytes = 32
|
||||
|
||||
var ErrCredentialAlreadyExists = errors.New("active device credential already exists")
|
||||
|
||||
type DatabaseSecretResolver struct {
|
||||
pool *pgxpool.Pool
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
func NewDatabaseSecretResolver(pool *pgxpool.Pool, masterKeyHex string) (*DatabaseSecretResolver, error) {
|
||||
if pool == nil {
|
||||
return nil, errors.New("device credential database is required")
|
||||
}
|
||||
key, err := hex.DecodeString(strings.TrimSpace(masterKeyHex))
|
||||
if err != nil || len(key) != databaseCredentialKeyBytes {
|
||||
return nil, errors.New("device credential master key must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device credential cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create device credential AEAD: %w", err)
|
||||
}
|
||||
return &DatabaseSecretResolver{pool: pool, aead: aead}, nil
|
||||
}
|
||||
|
||||
func (r *DatabaseSecretResolver) Resolve(ctx context.Context, reference string) ([]byte, error) {
|
||||
var encrypted, nonce []byte
|
||||
var version int64
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT encrypted_secret,nonce,key_version
|
||||
FROM device_credential
|
||||
WHERE credential_ref=$1 AND status='active'`, strings.TrimSpace(reference),
|
||||
).Scan(&encrypted, &nonce, &version)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read device credential: %w", err)
|
||||
}
|
||||
secret, err := r.aead.Open(nil, nonce, encrypted, credentialAAD(reference, version))
|
||||
if err != nil || len(secret) < 32 {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// ImportMissingLegacyCredentials moves existing deployment credentials into
|
||||
// the database without replacing an already imported or rotated credential.
|
||||
func (r *DatabaseSecretResolver) ImportMissingLegacyCredentials(ctx context.Context, credentials map[string][]byte) error {
|
||||
for reference, secret := range credentials {
|
||||
if len(secret) < 32 {
|
||||
return ErrCredentialInvalid
|
||||
}
|
||||
var deviceID, tenantID string
|
||||
err := r.pool.QueryRow(ctx, `SELECT id::text,tenant_id::text FROM device WHERE credential_ref=$1`, reference).Scan(&deviceID, &tenantID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve legacy device credential %s: %w", reference, err)
|
||||
}
|
||||
nonce := make([]byte, r.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return fmt.Errorf("generate device credential nonce: %w", err)
|
||||
}
|
||||
const version int64 = 1
|
||||
encrypted := r.aead.Seal(nil, nonce, secret, credentialAAD(reference, version))
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO device_credential(device_id,tenant_id,credential_ref,key_version,encrypted_secret,nonce)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6)
|
||||
ON CONFLICT (device_id) DO NOTHING`, deviceID, tenantID, reference, version, encrypted, nonce); err != nil {
|
||||
return fmt.Errorf("import legacy device credential %s: %w", reference, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DatabaseSecretResolver) Provision(ctx context.Context, devicePublicID string, secret []byte, rotate bool) (int64, error) {
|
||||
if len(secret) < 32 {
|
||||
return 0, ErrCredentialInvalid
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin device credential provisioning: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
version, err := r.ProvisionTx(ctx, tx, devicePublicID, secret, rotate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit device credential provisioning: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// ProvisionTx stores a credential inside a caller-owned transaction so device
|
||||
// registration and first credential issuance can commit atomically.
|
||||
func (r *DatabaseSecretResolver) ProvisionTx(ctx context.Context, tx pgx.Tx, devicePublicID string, secret []byte, rotate bool) (int64, error) {
|
||||
if len(secret) < 32 {
|
||||
return 0, ErrCredentialInvalid
|
||||
}
|
||||
var deviceID, tenantID, reference string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id::text,tenant_id::text,credential_ref FROM device
|
||||
WHERE public_id=$1::uuid AND status <> 'revoked' FOR UPDATE`, strings.TrimSpace(devicePublicID),
|
||||
).Scan(&deviceID, &tenantID, &reference); errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrDeviceNotFound
|
||||
} else if err != nil {
|
||||
return 0, fmt.Errorf("resolve device for credential provisioning: %w", err)
|
||||
}
|
||||
var currentVersion int64
|
||||
err := tx.QueryRow(ctx, `SELECT key_version FROM device_credential WHERE device_id=$1::uuid FOR UPDATE`, deviceID).Scan(¤tVersion)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf("read current device credential: %w", err)
|
||||
}
|
||||
if err == nil && !rotate {
|
||||
return 0, ErrCredentialAlreadyExists
|
||||
}
|
||||
version := currentVersion + 1
|
||||
nonce := make([]byte, r.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return 0, fmt.Errorf("generate device credential nonce: %w", err)
|
||||
}
|
||||
encrypted := r.aead.Seal(nil, nonce, secret, credentialAAD(reference, version))
|
||||
if currentVersion == 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO device_credential(device_id,tenant_id,credential_ref,key_version,encrypted_secret,nonce)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6)`, deviceID, tenantID, reference, version, encrypted, nonce)
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE device_credential SET key_version=$2,encrypted_secret=$3,nonce=$4,status='active',
|
||||
rotated_at=now(),revoked_at=NULL
|
||||
WHERE device_id=$1::uuid`, deviceID, version, encrypted, nonce)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store device credential: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (r *DatabaseSecretResolver) Revoke(ctx context.Context, devicePublicID string) error {
|
||||
result, err := r.pool.Exec(ctx, `
|
||||
UPDATE device_credential dc SET status='revoked',revoked_at=now()
|
||||
FROM device d WHERE d.id=dc.device_id AND d.public_id=$1::uuid AND dc.status='active'`, strings.TrimSpace(devicePublicID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke device credential: %w", err)
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func credentialAAD(reference string, version int64) []byte {
|
||||
return []byte(fmt.Sprintf("device-credential-v1\n%s\n%d", strings.TrimSpace(reference), version))
|
||||
}
|
||||
Reference in New Issue
Block a user