@sanning/anchor-s3
v0.4.0
Published
Anchor-as-you-store for S3: wrap your S3 client so every putObject also anchors a tamper-evident provenance record to Arweave and writes it beside the object as a verify-anywhere .provenance.json sidecar. Bytes are hashed locally, never uploaded.
Readme
@sanning/anchor-s3
Anchor as you store. Wrap your S3 client once, and every putObject also:
- stores your object exactly as asked,
- anchors a tamper-evident provenance record — permanent and public on Arweave (your bytes are hashed locally — they never leave your infrastructure), and
- writes that record beside the object as
<key>.provenance.json, so the bucket carries its own offline-verifiable audit trail.
npm install @sanning/anchor-s3 @sanning/anchor @aws-sdk/client-s3import { S3Client } from "@aws-sdk/client-s3";
import { createAnchorer } from "@sanning/anchor";
import { anchoredS3 } from "@sanning/anchor-s3";
const s3 = anchoredS3(new S3Client({}), createAnchorer()); // dev mode: zero config
const { receipt, provenanceKey, location, ref } = await s3.putObject({
Bucket: "models",
Key: "prod/scorer.pkl",
Body: modelBytes,
});
receipt.txId; // permanent Arweave anchor for these exact bytes
provenanceKey; // "prod/scorer.pkl.provenance.json" — verify offline, anytime
ref; // "s3+opaque:<sha256>" — the locator that went public
location; // "s3://models/prod/scorer.pkl" — stays with youBody must be a string or Uint8Array (it is hashed in-process; streams aren't accepted here).
Your bucket and key are not published
The provenance record carries a payload locator — a pointer to where the anchored bytes live. That locator sits inside the signed scope (event.ref, and payload_ref in the on-chain envelope when a logStore is configured), so whatever goes in it is permanent, public, and un-redactable.
Bucket names and object keys routinely embed customer names, tenant ids, agent names, and model names. So the default locator is opaque:
s3+opaque:<sha256 hex of "s3://<bucket>/<key>">That is stable and correlatable — the same object always yields the same locator, so enumeration and reverse lookup still work — and confirmable by anyone who already holds the path, while revealing nothing to anyone who does not. The real path is written to the sidecar as location, which lives in the same bucket as the object it describes, so it discloses nothing to its reader.
Confirm the binding with one line:
import { opaqueLocator } from "@sanning/anchor-s3";
(await opaqueLocator(sidecar.location)) === sidecar.ref; // trueIntegrity does not depend on the locator. Per envelope-spec.md §2 a locator is a producer-asserted pointer, never a trust signal — "a lying locator is caught by the hash, never trusted on its own." Verification binds on content_hash / payload_hash, which are unchanged.
Need the old self-describing s3://bucket/key form? Ask for it explicitly, per wrapper:
const s3 = anchoredS3(new S3Client({}), createAnchorer(), { locator: "plaintext" });That publishes the bucket and key permanently. It is the right choice when the path is already public (an open data bucket, a published model registry) and self-describing evidence is worth more than the disclosure.
Verifying a sidecar
The sidecar is JSON: { txId, gatewayUrl, envelope, record, contentHash, environment, location, ref, locatorMode }. Anyone with the object and its sidecar can verify offline with the read-only @sanning/proof — no Sanning service in the trust path:
import { ed25519Verify, jcs, sha256Hex, utf8 } from "@sanning/proof";
const sidecar = JSON.parse(sidecarJson);
// 1. The object bytes are what was anchored.
(await sha256Hex(objectBytes)) === sidecar.record.event.content_hash;
// 2. The record is what the envelope committed to.
(await sha256Hex(utf8(jcs(sidecar.record)))) === sidecar.envelope.payload_hash;
// 3. The envelope is authentically signed.
const { signature, ...pre } = sidecar.envelope;
await ed25519Verify(signature, utf8(jcs(pre)), sidecar.envelope.public_key);Cross-check against the chain by fetching txId from the Sanning read front (https://console.sanning.io/read/<txId>) or any public Arweave gateway (https://<gateway>/raw/<txId>, e.g. arweave.net) and comparing it to sidecar.envelope.
Semantics
- If anchoring fails, the object is already stored but no sidecar is written — the put itself is never rolled back.
- The anchored locator defaults to opaque (above).
{ locator: "plaintext" }opts back into publishing bucket + key; any other value throws at wrap time rather than disclosing by accident. - For production credentials (explicit signing key, funded wallet) and the typed-error table: see
@sanning/anchor.
