fix(hub): the blob seal compared two clocks that need not agree

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
This commit is contained in:
Snow Lee
2026-08-02 22:50:46 -07:00
co-authored by Claude Opus 5
parent 963843832f
commit b1e9d20a57
+18 -1
View File
@@ -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