Files
nekonest-cloud/relay/internal/tenantstore/phone.go
T

338 lines
9.7 KiB
Go

package tenantstore
import (
"crypto/subtle"
"database/sql"
"errors"
"fmt"
"strings"
"time"
corestore "github.com/klarkxy/nekonest/relaycore/store"
)
// PhoneIdentity is an independent phone client identity.
type PhoneIdentity = corestore.PhoneIdentity
// PhoneAuth is the result of validating a phone bearer token.
type PhoneAuth = corestore.PhoneAuth
var (
ErrPhoneNotFound = corestore.ErrPhoneNotFound
ErrPhoneRevoked = corestore.ErrPhoneRevoked
ErrPhoneTokenInvalid = corestore.ErrPhoneTokenInvalid
ErrGrantNotFound = errors.New("device grant not found")
ErrGrantRevoked = errors.New("device grant revoked")
)
// CreatePhoneIdentity mints a new phone identity and returns the plaintext token once.
func (db *DB) CreatePhoneIdentity(name string) (phoneID, token string, err error) {
name = strings.TrimSpace(name)
if name == "" {
name = "Phone"
}
token = generateToken()
phoneRandom := generateToken()
if token == "" || phoneRandom == "" {
return "", "", errors.New("secure phone credential generation failed")
}
phoneID = "phone_" + phoneRandom[:16]
now := time.Now().Unix()
_, err = db.conn.Exec(
`INSERT INTO phone_identities (id, name, token_hash, ed25519_public, x25519_public, created_at, last_seen, revoked_at)
VALUES (?, ?, ?, '', '', ?, ?, 0)`,
phoneID, name, hashToken(token), now, now,
)
if err != nil {
return "", "", err
}
return phoneID, token, nil
}
// ValidatePhoneToken returns phone auth for an active token.
func (db *DB) ValidatePhoneToken(token string) (*PhoneAuth, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrPhoneTokenInvalid
}
row := db.conn.QueryRow(
`SELECT id, name, token_hash, revoked_at FROM phone_identities WHERE token_hash = ?`,
hashToken(token),
)
var id, name, tokenHash string
var revokedAt int64
if err := row.Scan(&id, &name, &tokenHash, &revokedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPhoneTokenInvalid
}
return nil, err
}
// Constant-time compare of hashes (already looked up by hash; still guards timing).
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hashToken(token))) != 1 {
return nil, ErrPhoneTokenInvalid
}
if revokedAt > 0 {
return nil, ErrPhoneRevoked
}
_, _ = db.conn.Exec(`UPDATE phone_identities SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
return &PhoneAuth{PhoneID: id, Name: name}, nil
}
// GetPhone returns a phone identity by id.
func (db *DB) GetPhone(id string) (*PhoneIdentity, error) {
row := db.conn.QueryRow(
`SELECT id, name, ed25519_public, x25519_public, created_at, last_seen, revoked_at
FROM phone_identities WHERE id = ?`, id,
)
var p PhoneIdentity
if err := row.Scan(&p.ID, &p.Name, &p.Ed25519Public, &p.X25519Public, &p.CreatedAt, &p.LastSeen, &p.RevokedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPhoneNotFound
}
return nil, err
}
return &p, nil
}
// ListPhones returns all phone identities (including revoked).
func (db *DB) ListPhones() ([]*PhoneIdentity, error) {
rows, err := db.conn.Query(
`SELECT id, name, ed25519_public, x25519_public, created_at, last_seen, revoked_at
FROM phone_identities ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PhoneIdentity
for rows.Next() {
var p PhoneIdentity
if err := rows.Scan(&p.ID, &p.Name, &p.Ed25519Public, &p.X25519Public, &p.CreatedAt, &p.LastSeen, &p.RevokedAt); err != nil {
return nil, err
}
out = append(out, &p)
}
return out, rows.Err()
}
// SetPhonePublicKeys stores E2E public keys for a phone.
func (db *DB) SetPhonePublicKeys(phoneID, ed25519Pub, x25519Pub string) error {
res, err := db.conn.Exec(
`UPDATE phone_identities SET ed25519_public = ?, x25519_public = ? WHERE id = ? AND revoked_at = 0`,
ed25519Pub, x25519Pub, phoneID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrPhoneNotFound
}
return nil
}
// RevokePhone marks a phone revoked and revokes all its device grants.
func (db *DB) RevokePhone(phoneID string) error {
now := time.Now().Unix()
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(
`UPDATE phone_identities SET revoked_at = ? WHERE id = ? AND revoked_at = 0`,
now, phoneID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
// Already revoked or missing — check existence
var exists int
if err := tx.QueryRow(`SELECT 1 FROM phone_identities WHERE id = ?`, phoneID).Scan(&exists); err != nil {
return ErrPhoneNotFound
}
return nil
}
if _, err := tx.Exec(
`UPDATE phone_device_grants SET revoked_at = ? WHERE phone_id = ? AND revoked_at = 0`,
now, phoneID,
); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM key_packages WHERE phone_id = ?`, phoneID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM push_subscriptions WHERE phone_id = ?`, phoneID); err != nil {
return err
}
return tx.Commit()
}
// GrantPhoneDevice creates or reactivates a phone→device grant.
func (db *DB) GrantPhoneDevice(phoneID, deviceID string) error {
if phoneID == "" || deviceID == "" {
return fmt.Errorf("phone_id and device_id required")
}
// Ensure phone is active
p, err := db.GetPhone(phoneID)
if err != nil {
return err
}
if p.RevokedAt > 0 {
return ErrPhoneRevoked
}
if !db.DeviceExists(deviceID) {
return fmt.Errorf("unknown device")
}
now := time.Now().Unix()
_, err = db.conn.Exec(`
INSERT INTO phone_device_grants (phone_id, device_id, paired_at, revoked_at)
VALUES (?, ?, ?, 0)
ON CONFLICT(phone_id, device_id) DO UPDATE SET
paired_at = excluded.paired_at,
revoked_at = 0
`, phoneID, deviceID, now)
return err
}
// RevokePhoneDeviceGrant revokes one phone→device grant.
func (db *DB) RevokePhoneDeviceGrant(phoneID, deviceID string) error {
now := time.Now().Unix()
res, err := db.conn.Exec(
`UPDATE phone_device_grants SET revoked_at = ? WHERE phone_id = ? AND device_id = ? AND revoked_at = 0`,
now, phoneID, deviceID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrGrantNotFound
}
_, _ = db.conn.Exec(
`DELETE FROM key_packages WHERE phone_id = ? AND device_id = ?`,
phoneID, deviceID,
)
_, _ = db.conn.Exec(
`DELETE FROM push_subscriptions WHERE phone_id = ? AND device_id = ?`,
phoneID, deviceID,
)
return nil
}
// PhoneHasDeviceGrant reports whether phone may access device.
func (db *DB) PhoneHasDeviceGrant(phoneID, deviceID string) bool {
if phoneID == "" || deviceID == "" {
return false
}
var n int
err := db.conn.QueryRow(
`SELECT 1 FROM phone_device_grants
WHERE phone_id = ? AND device_id = ? AND revoked_at = 0 LIMIT 1`,
phoneID, deviceID,
).Scan(&n)
return err == nil && n == 1
}
// ListPhoneDeviceIDs returns active device grants for a phone.
func (db *DB) ListPhoneDeviceIDs(phoneID string) ([]string, error) {
rows, err := db.conn.Query(
`SELECT device_id FROM phone_device_grants
WHERE phone_id = ? AND revoked_at = 0 ORDER BY paired_at DESC`,
phoneID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// UpsertKeyPackage stores a wrapped key package for a phone/device/scope.
func (db *DB) UpsertKeyPackage(phoneID, deviceID, scope, sessionID string, epoch uint64, wrappedKey, nonce string) error {
now := time.Now().Unix()
_, err := db.conn.Exec(`
INSERT INTO key_packages (phone_id, device_id, scope, session_id, epoch, wrapped_key, nonce, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(phone_id, device_id, scope, session_id, epoch) DO UPDATE SET
wrapped_key = excluded.wrapped_key,
nonce = excluded.nonce,
created_at = excluded.created_at
`, phoneID, deviceID, scope, sessionID, epoch, wrappedKey, nonce, now)
return err
}
// PhoneGrant describes an active phone→device grant with optional E2E pubs.
type PhoneGrant = corestore.PhoneGrant
// ListPhoneGrantsForDevice returns active grants for a host (daemon key wrap).
func (db *DB) ListPhoneGrantsForDevice(deviceID string) ([]*PhoneGrant, error) {
rows, err := db.conn.Query(`
SELECT g.phone_id, g.device_id, p.ed25519_public, p.x25519_public, g.paired_at
FROM phone_device_grants g
JOIN phone_identities p ON p.id = g.phone_id
WHERE g.device_id = ? AND g.revoked_at = 0 AND p.revoked_at = 0
ORDER BY g.paired_at DESC`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PhoneGrant
for rows.Next() {
var g PhoneGrant
if err := rows.Scan(&g.PhoneID, &g.DeviceID, &g.Ed25519Public, &g.X25519Public, &g.PairedAt); err != nil {
return nil, err
}
out = append(out, &g)
}
return out, rows.Err()
}
// ListKeyPackages returns key packages for a phone (optionally filtered by device).
func (db *DB) ListKeyPackages(phoneID, deviceID string) ([]map[string]any, error) {
q := `SELECT phone_id, device_id, scope, session_id, epoch, wrapped_key, nonce, created_at
FROM key_packages WHERE phone_id = ?`
args := []any{phoneID}
if deviceID != "" {
q += ` AND device_id = ?`
args = append(args, deviceID)
}
q += ` ORDER BY created_at DESC`
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var pid, did, scope, sid, wk, nonce string
var epoch uint64
var created int64
if err := rows.Scan(&pid, &did, &scope, &sid, &epoch, &wk, &nonce, &created); err != nil {
return nil, err
}
out = append(out, map[string]any{
"phone_id": pid,
"device_id": did,
"scope": scope,
"session_id": sid,
"epoch": epoch,
"wrapped_key": wk,
"nonce": nonce,
"created_at": created,
})
}
return out, rows.Err()
}