From b1e9d20a578843f57d10071d654fa020ce04f0e6 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sun, 2 Aug 2026 22:50:46 -0700 Subject: [PATCH] fix(hub): the blob seal compared two clocks that need not agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification cache is sound on one argument: once a stored blob is older than the presign TTL, no live URL for it can exist, so the bytes cannot change again and the hash need not be recomputed. That argument is about time, and the two times came from different machines — o.Modified is the object store's clock, time.Since is the hub's. A hub running ahead of storage overstates the object's age and seals it while a minted URL is still live; a replay through that URL is then served from cache for the rest of the process's life. NTP makes it unlikely and a container without it, or a VM resumed from suspend, makes it reachable. Seal after the TTL plus an hour instead. Sealing early buys nothing — the blob is immutable either way — so the margin costs a few extra hashes on a young blob and removes a dependency on two clocks agreeing that nothing in the process can verify. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --- internal/webapp/server.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 0edad79..019afa7 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -378,13 +378,30 @@ func (r *RemoteSource) verify(ctx context.Context, sha string) error { if hex.EncodeToString(h.Sum(nil)) != sha { return fmt.Errorf("stored content does not hash to its key") } + // sealAfter, not presignTTL: o.Modified is the STORAGE service's clock and + // time.Since is the hub's. A hub whose clock runs ahead of storage would + // otherwise overstate the object's age and seal it while a minted URL is + // still live — after which a replay is served from cache for the life of + // the process. Waiting a multiple of the TTL costs a handful of extra + // hashes on a young blob and removes the dependency on the two clocks + // agreeing, which nothing here can check. if o, ok, err := r.blobStat(ctx, sha); err == nil && ok && - !o.Modified.IsZero() && time.Since(o.Modified) > r.presignTTL() { + !o.Modified.IsZero() && time.Since(o.Modified) > r.sealAfter() { r.sealed.Store(sha, struct{}{}) } return nil } +// sealAfter is how old a stored blob must be before its verification may be +// cached: the presign TTL plus a margin for clock skew between the hub and the +// object store. The margin is the whole point — the correctness argument is +// "no live URL can exist any more", and that is a claim about time measured on +// two machines the hub cannot reconcile. +func (r *RemoteSource) sealAfter() time.Duration { + const skewMargin = time.Hour + return r.presignTTL() + skewMargin +} + // Identity is the device identity uploads are journaled under. type Identity struct { ID, Name, Author string