338 lines
9.7 KiB
Go
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()
|
|
}
|