[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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user