[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))
}