From 0334d7fe9380be97ad78eeae7512f0e048067ee2 Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Mon, 19 Oct 2026 02:26:31 +0100
Subject: [PATCH] [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.
---
database/migrations/003_hash_device_id.sql | 46 +++++++++++++
src/actions/subscriptions.go | 26 +++++--
src/db/subscriptions.go | 19 ++++-
src/internal/deviceid/deviceid.go | 77 +++++++++++++++++++++
src/internal/deviceid/deviceid_test.go | 80 ++++++++++++++++++++++
src/realtime/auth.go | 4 +-
6 files changed, 242 insertions(+), 10 deletions(-)
create mode 100644 database/migrations/003_hash_device_id.sql
create mode 100644 src/internal/deviceid/deviceid.go
create mode 100644 src/internal/deviceid/deviceid_test.go
diff --git a/database/migrations/003_hash_device_id.sql b/database/migrations/003_hash_device_id.sql
new file mode 100644
index 0000000..2916bf5
--- /dev/null
+++ b/database/migrations/003_hash_device_id.sql
@@ -0,0 +1,46 @@
+\c dcntrlized;
+
+-- 003_hash_device_id.sql — store SHA-256 digests of device ids, not the values.
+--
+-- Why: alarm.subscriptions.device_id is a stable pseudonymous client value
+-- (dev_ + 96 random bits). It is an idempotency key, not a credential, but a
+-- database dump should not be a usable list of device identifiers. The service
+-- hashes every caller-supplied value before it touches the table, so new rows
+-- are already stored as 'sha256:<64 lowercase hex>'.
+--
+-- Why this does not break idempotency (the thing someone will worry about):
+-- SHA-256 equality is exactly equality of the raw values. Two raw ids still
+-- collide iff their digests do, so the
+-- subscriptions_alert_user_device_key UNIQUE (alert_id, subscriber_user_id,
+-- device_id) constraint keeps its meaning and the re-join lookup still finds the
+-- same row. The hash is one-way, so the raw value cannot be recovered from it —
+-- which is the point.
+--
+-- The encoding matches Alarm/src/internal/deviceid.Hash exactly:
+-- 'sha256:' || encode(sha256(convert_to(device_id,'UTF8')),'hex')
+-- and is self-describing: a value starting with 'sha256:' is a digest, never a
+-- client id. Stored length is 71 bytes, inside the column's VARCHAR(128).
+--
+-- sha256(bytea) is a core PostgreSQL function since 11 (no pgcrypto needed); the
+-- dev database is 16.14. The migration is guarded on that anyway: if sha256 is
+-- somehow unavailable it fails loudly here rather than storing raw ids mixed
+-- with digests.
+--
+-- Idempotent: the NOT LIKE 'sha256:%' predicate skips rows already hashed, so
+-- re-applying the sequence is a clean no-op (same pattern as 002).
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
+ RETURN;
+ END IF;
+ IF to_regprocedure('sha256(bytea)') IS NOT NULL THEN
+ UPDATE alarm.subscriptions
+ SET device_id = 'sha256:' || encode(sha256(convert_to(device_id, 'UTF8')), 'hex')
+ WHERE device_id NOT LIKE 'sha256:%';
+ ELSE
+ RAISE EXCEPTION
+ 'sha256(bytea) is unavailable; device_id hashing would store mixed raw/digest rows';
+ END IF;
+END
+$$;
diff --git a/src/actions/subscriptions.go b/src/actions/subscriptions.go
index 461c74d..9698817 100644
--- a/src/actions/subscriptions.go
+++ b/src/actions/subscriptions.go
@@ -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))
}
diff --git a/src/db/subscriptions.go b/src/db/subscriptions.go
index 561c621..c09708a 100644
--- a/src/db/subscriptions.go
+++ b/src/db/subscriptions.go
@@ -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.
diff --git a/src/internal/deviceid/deviceid.go b/src/internal/deviceid/deviceid.go
new file mode 100644
index 0000000..f0b6ed4
--- /dev/null
+++ b/src/internal/deviceid/deviceid.go
@@ -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 .
+
+// 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
+}
diff --git a/src/internal/deviceid/deviceid_test.go b/src/internal/deviceid/deviceid_test.go
new file mode 100644
index 0000000..c72eca9
--- /dev/null
+++ b/src/internal/deviceid/deviceid_test.go
@@ -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 .
+
+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)
+ }
+ }
+}
diff --git a/src/realtime/auth.go b/src/realtime/auth.go
index 1e14208..0ae3600 100644
--- a/src/realtime/auth.go
+++ b/src/realtime/auth.go
@@ -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 {