[feat](alarm): store device ids as sha256 digests
device_id is a stable pseudonymous idempotency key, not a credential; a database dump should not be a usable list of device identifiers. Hash every caller-supplied value before it reaches the table, keep raw values out of logs and subscription views, and migrate existing rows with the identical SQL encoding.
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,6 @@ type subscriptionView struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username,omitempty"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceLabel string `json:"deviceLabel,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -39,6 +39,15 @@ type subscriptionView struct {
|
||||
|
||||
// newSubscriptionView maps a row to the wire view.
|
||||
//
|
||||
// `deviceId` is deliberately NOT serialised: it is a stable pseudonymous
|
||||
// idempotency key, not a credential, and the admin has no need for every
|
||||
// subscriber's device identifier. The subscription `id` is the revocation
|
||||
// handle, and `created` (joined-at) plus `lastSeenAt`/`deviceLabel`/`platform`
|
||||
// distinguish several identical-looking devices without identifying any of
|
||||
// them. The column and the request parameter stay: `(alert_id,
|
||||
// subscriber_user_id, device_id)` is the unique key that makes a re-join
|
||||
// idempotent.
|
||||
//
|
||||
// `username` is deliberately never populated: it lives in the auth service's
|
||||
// schema, and the Alarm service does not read another service's tables. The
|
||||
// field is omitted rather than sent empty.
|
||||
@@ -46,7 +55,6 @@ func newSubscriptionView(s *db.Subscription) subscriptionView {
|
||||
return subscriptionView{
|
||||
ID: s.ID.String(),
|
||||
UserID: s.SubscriberUserID.String(),
|
||||
DeviceID: s.DeviceID,
|
||||
DeviceLabel: deref(s.DeviceLabel),
|
||||
Platform: deref(s.Platform),
|
||||
Enabled: s.Enabled,
|
||||
@@ -94,6 +102,10 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
validationError(w, "%v", err)
|
||||
return
|
||||
}
|
||||
// The raw value never reaches the database: every comparison (the re-join
|
||||
// lookup here, the unique key in INSERT) uses the digest. The client keeps
|
||||
// sending the raw value and never learns its hash.
|
||||
storedDeviceID := deviceid.Hash(deviceID)
|
||||
label, err := validateOptional(req.DeviceLabel, "deviceLabel", maxLabelLen)
|
||||
if err != nil {
|
||||
validationError(w, "%v", err)
|
||||
@@ -120,10 +132,10 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, deviceID)
|
||||
existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, storedDeviceID)
|
||||
switch {
|
||||
case err == nil:
|
||||
Debug(action, "POST", "idempotent re-join alert=%s sub=%s device=%s", alertID, existing.ID, deviceID)
|
||||
Debug(action, "POST", "idempotent re-join alert=%s sub=%s", alertID, existing.ID)
|
||||
writeJSON(w, http.StatusOK, newSubscriptionView(existing))
|
||||
return
|
||||
case !errors.Is(err, db.ErrNotFound):
|
||||
@@ -134,14 +146,14 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
|
||||
AlertID: alertID,
|
||||
SubscriberUserID: p.ID,
|
||||
DeviceID: deviceID,
|
||||
DeviceID: storedDeviceID,
|
||||
DeviceLabel: label,
|
||||
Platform: platform,
|
||||
})
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
// Two concurrent joins for the same device: the sequential repeat path
|
||||
// above already handled the normal re-scan case.
|
||||
Warn(action, "POST", "concurrent duplicate join alert=%s user=%s device=%s", alertID, p.ID, deviceID)
|
||||
Warn(action, "POST", "concurrent duplicate join alert=%s user=%s", alertID, p.ID)
|
||||
writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
|
||||
return
|
||||
}
|
||||
@@ -150,7 +162,7 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
return
|
||||
}
|
||||
|
||||
Debug(action, "POST", "joined alert=%s user=%s sub=%s device=%s", alertID, p.ID, sub.ID, deviceID)
|
||||
Debug(action, "POST", "joined alert=%s user=%s sub=%s", alertID, p.ID, sub.ID)
|
||||
writeJSON(w, http.StatusOK, newSubscriptionView(sub))
|
||||
}
|
||||
|
||||
|
||||
+17
-2
@@ -59,16 +59,31 @@ func scanSubscription(row pgx.Row) (*Subscription, error) {
|
||||
// sequential re-scan path is handled by the caller's lookup and never reaches
|
||||
// this error.
|
||||
func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) {
|
||||
tx, err := Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
const q = `INSERT INTO alarm.subscriptions
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING ` + subscriptionColumns
|
||||
row, err := scanSubscription(Pool().QueryRow(ctx, q,
|
||||
row, err := scanSubscription(tx.QueryRow(ctx, q,
|
||||
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return row, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := recordEvent(ctx, tx, s.AlertID, &s.SubscriberUserID, EventJoined, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetSubscriptionByDevice is the idempotency lookup for joinAlert.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package deviceid derives the at-rest form of a subscription's device id.
|
||||
//
|
||||
// The client generates a raw pseudonymous value (dev_ + 96 random bits) and
|
||||
// sends it on join and on the WebSocket handshake. The service never stores that
|
||||
// value: every path hashes the caller's input first, so what lands in
|
||||
// alarm.subscriptions.device_id is a self-describing SHA-256 digest
|
||||
// ("sha256:<64 lowercase hex>", 71 bytes, inside VARCHAR(128)).
|
||||
//
|
||||
// Why a hash and not the raw value: a database dump should not be a usable list
|
||||
// of device identifiers. Why it does not break idempotency: equality is preserved
|
||||
// — two raw ids collide iff their digests do — so the
|
||||
// (alert_id, subscriber_user_id, device_id) unique key and the join lookup mean
|
||||
// exactly what they meant before hashing. The digest is not a credential: it is
|
||||
// a stable key, and the same raw id on the same alert is deliberately
|
||||
// correlatable, because that is what idempotency requires.
|
||||
//
|
||||
// Hash always hashes its argument. A stored value is never fed back through it:
|
||||
// passing "sha256:…" from the database back into Hash would double-hash and
|
||||
// silently break matching.
|
||||
package deviceid
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Prefix makes a stored digest self-describing: a value that starts with it
|
||||
// is a SHA-256 hex digest, not an id anyone can compare against a client.
|
||||
Prefix = "sha256:"
|
||||
// HexLen is the length of the hex-encoded SHA-256 digest.
|
||||
HexLen = 64
|
||||
// StoredLen is the full stored length: Prefix + HexLen.
|
||||
StoredLen = len(Prefix) + HexLen
|
||||
)
|
||||
|
||||
// Hash returns the stored form of a raw client device id.
|
||||
func Hash(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return Prefix + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// IsHash reports whether s is already in the stored digest form. It exists for
|
||||
// tests, diagnostics and the migration guard — never to skip hashing a caller's
|
||||
// input.
|
||||
func IsHash(s string) bool {
|
||||
if !strings.HasPrefix(s, Prefix) || len(s) != StoredLen {
|
||||
return false
|
||||
}
|
||||
// Lowercase hex only: the SQL migration and Hash both emit lowercase, and a
|
||||
// case-insensitive check here would let an uppercase row masquerade as the
|
||||
// canonical form.
|
||||
for _, char := range s[len(Prefix):] {
|
||||
switch {
|
||||
case char >= '0' && char <= '9', char >= 'a' && char <= 'f':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package deviceid
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestHashFormat pins the exact at-rest encoding the SQL migration produces:
|
||||
// 'sha256:' || encode(sha256(convert_to(raw,'UTF8')),'hex').
|
||||
func TestHashFormat(t *testing.T) {
|
||||
raw := "dev_0123456789abcdef01234567"
|
||||
got := Hash(raw)
|
||||
|
||||
if !IsHash(got) {
|
||||
t.Fatalf("Hash(%q) = %q is not in stored form", raw, got)
|
||||
}
|
||||
if len(got) != StoredLen || StoredLen != 71 {
|
||||
t.Fatalf("stored length = %d, want 71", len(got))
|
||||
}
|
||||
// Known vector: sha256("") is e3b0c442...; test the encoding end-to-end.
|
||||
if want := Prefix + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; Hash("") != want {
|
||||
t.Fatalf("Hash(\"\") = %q, want %q", Hash(""), want)
|
||||
}
|
||||
if got == raw {
|
||||
t.Fatal("Hash returned the raw value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPreservesEquality is the property the unique key depends on: distinct
|
||||
// raw ids stay distinct, and the same raw id always maps to the same digest.
|
||||
func TestHashPreservesEquality(t *testing.T) {
|
||||
a := Hash("dev_aaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
b := Hash("dev_bbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
if a == b {
|
||||
t.Fatal("distinct ids collided")
|
||||
}
|
||||
if a != Hash("dev_aaaaaaaaaaaaaaaaaaaaaaaa") {
|
||||
t.Fatal("hash is not stable")
|
||||
}
|
||||
// A value that already looks like a digest is still hashed (it is caller
|
||||
// input), never passed through.
|
||||
doubleHashed := Hash(a)
|
||||
if doubleHashed == a {
|
||||
t.Fatal("Hash passed through an already-hashed value")
|
||||
}
|
||||
if !IsHash(doubleHashed) {
|
||||
t.Fatalf("double hash is not in stored form: %q", doubleHashed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsHashRejectsMalformedValues keeps IsHash honest: only the exact encoding
|
||||
// counts, so a future change to the format cannot silently pass old rows.
|
||||
func TestIsHashRejectsMalformedValues(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
"dev_0123456789abcdef01234567",
|
||||
"sha256:",
|
||||
"sha256:nothex",
|
||||
"sha256:" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85", // 63 chars
|
||||
"sha256:" + "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855", // uppercase
|
||||
"sha512:" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
}
|
||||
for _, s := range bad {
|
||||
if IsHash(s) {
|
||||
t.Fatalf("IsHash(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
|
||||
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
||||
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
|
||||
"github.com/google/uuid"
|
||||
@@ -135,7 +136,8 @@ func authenticateWS(r *http.Request) (*Identity, error) {
|
||||
// the alert.
|
||||
func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscription, error) {
|
||||
if deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")); deviceID != "" {
|
||||
return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceID)
|
||||
// The client sends the raw value; the row stores its digest.
|
||||
return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceid.Hash(deviceID))
|
||||
}
|
||||
ids, err := db.UserSubscriptionIDs(r.Context(), alertID, userID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user