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 {