[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:
2026-10-19 02:26:31 +01:00
parent 3a2da52b7a
commit 0334d7fe93
6 changed files with 242 additions and 10 deletions
+19 -7
View File
@@ -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
View File
@@ -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.
+77
View File
@@ -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
}
+80
View File
@@ -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)
}
}
}
+3 -1
View File
@@ -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 {