Policy

totp_transfer

Rego and WASM for the Newton policy. It allows attested transfers of at most 10 USDC when WASM verifies a 6-digit TOTP whose challenge matches this intent.

0x9Ce44Bc8c2232dBE7F2574C96Fc7ef2fCA3a401B

Attack demo · Policy client · Guard · Newton explorer

policy/policy.rego

package totp_transfer

import future.keywords

default allow := false

# Named ABI string must match configs/intent.json (UTF-8) and the PolicyClient.
transfer_sig := "function transfer(address recipient, uint256 amount, bytes32 tapNonce)"

# Challenge binds the six-field intent, including tapNonce inside input.data.
# Simulate/gateway expose chain_id (not chainId). Newton's Rego kernel has no
# crypto.sha256, so the TOTP challenge is UTF-8 of this preimage and Rego
# compares the string. A reused code cannot authorize a different recipient,
# amount, or tapNonce unless it is still inside the TOTP window.
#   lower(from)|lower(to)|value|lower(data)|chain_id|decoded_function_signature
challenge_preimage := concat("|", [
	lower(input.from),
	lower(input.to),
	sprintf("%v", [input.value]),
	lower(input.data),
	sprintf("%v", [input.chain_id]),
	input.decoded_function_signature,
])

allow if {
	data.wasm.success
	input.decoded_function_signature == transfer_sig
	to_number(input.decoded_function_arguments[1]) <= data.params.maxAmount
	data.wasm.challenge == challenge_preimage
}

policy/policy.js

import { fetch as httpFetch } from "newton:provider/http@0.2.0";
import { get as getSecrets } from "newton:provider/secrets@0.2.0";

function asBytes(value) {
  if (value instanceof Uint8Array) return value;
  if (Array.isArray(value)) return new Uint8Array(value);
  if (value && value.val != null) return asBytes(value.val);
  return new Uint8Array(value ?? []);
}

function parseArgs(wasm_args) {
  if (wasm_args == null || wasm_args === "") return {};
  if (typeof wasm_args === "object") return wasm_args;
  return JSON.parse(wasm_args);
}

function fail(error, extra) {
  return JSON.stringify({ success: false, challenge: null, error, ...extra });
}

function loadSecrets() {
  const result = getSecrets();
  if (typeof result === "string") throw new Error(result);
  if (result && result.tag === "err") throw new Error(String(result.val));
  const wrapped = result && result.val != null ? result.val : result;
  const bytes = asBytes(wrapped && wrapped.value != null ? wrapped.value : wrapped);
  return JSON.parse(new TextDecoder().decode(bytes));
}

function decodeBase32(input) {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const s = String(input ?? "")
    .toUpperCase()
    .replace(/[\s=-]+/g, "");
  if (!s || /[^A-Z2-7]/.test(s)) return null;
  let bits = 0;
  let value = 0;
  const out = [];
  for (let i = 0; i < s.length; i++) {
    value = (value << 5) | alphabet.indexOf(s[i]);
    bits += 5;
    if (bits >= 8) {
      out.push((value >>> (bits - 8)) & 255);
      bits -= 8;
    }
  }
  return new Uint8Array(out);
}

function normalizeCode(code, digits) {
  const s = String(code ?? "").replace(/\s+/g, "");
  if (!/^\d+$/.test(s) || s.length !== digits) return null;
  return s;
}

function codesEqual(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

// SHA-1 (20-byte digest). Empty-string: da39a3ee5e6b4b0d3255bfef95601890afd80709
function sha1(bytes) {
  const u8 = asBytes(bytes);
  const bitLenHi = Math.floor((u8.length * 8) / 0x100000000);
  const bitLenLo = (u8.length * 8) >>> 0;
  const withPad = u8.length + 1 + 8;
  const paddedLen = (withPad + 63) & ~63;
  const buf = new Uint8Array(paddedLen);
  buf.set(u8);
  buf[u8.length] = 0x80;
  const view = new DataView(buf.buffer);
  view.setUint32(paddedLen - 8, bitLenHi, false);
  view.setUint32(paddedLen - 4, bitLenLo, false);

  let h0 = 0x67452301;
  let h1 = 0xefcdab89;
  let h2 = 0x98badcfe;
  let h3 = 0x10325476;
  let h4 = 0xc3d2e1f0;
  const w = new Uint32Array(80);
  const rotl = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0;

  for (let off = 0; off < paddedLen; off += 64) {
    for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4, false);
    for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
    let a = h0,
      b = h1,
      c = h2,
      d = h3,
      e = h4;
    for (let i = 0; i < 80; i++) {
      let f, k;
      if (i < 20) {
        f = (b & c) | (~b & d);
        k = 0x5a827999;
      } else if (i < 40) {
        f = b ^ c ^ d;
        k = 0x6ed9eba1;
      } else if (i < 60) {
        f = (b & c) | (b & d) | (c & d);
        k = 0x8f1bbcdc;
      } else {
        f = b ^ c ^ d;
        k = 0xca62c1d6;
      }
      const temp = (rotl(a, 5) + f + e + k + w[i]) >>> 0;
      e = d;
      d = c;
      c = rotl(b, 30);
      b = a;
      a = temp;
    }
    h0 = (h0 + a) >>> 0;
    h1 = (h1 + b) >>> 0;
    h2 = (h2 + c) >>> 0;
    h3 = (h3 + d) >>> 0;
    h4 = (h4 + e) >>> 0;
  }

  const out = new Uint8Array(20);
  const ov = new DataView(out.buffer);
  ov.setUint32(0, h0, false);
  ov.setUint32(4, h1, false);
  ov.setUint32(8, h2, false);
  ov.setUint32(12, h3, false);
  ov.setUint32(16, h4, false);
  return out;
}

function hmacSha1(keyBytes, messageBytes) {
  const block = 64;
  let key = asBytes(keyBytes);
  if (key.length > block) key = sha1(key);
  const k = new Uint8Array(block);
  k.set(key);
  const ipad = new Uint8Array(block);
  const opad = new Uint8Array(block);
  for (let i = 0; i < block; i++) {
    ipad[i] = k[i] ^ 0x36;
    opad[i] = k[i] ^ 0x5c;
  }
  const msg = asBytes(messageBytes);
  const inner = new Uint8Array(block + msg.length);
  inner.set(ipad);
  inner.set(msg, block);
  const innerHash = sha1(inner);
  const outer = new Uint8Array(block + 20);
  outer.set(opad);
  outer.set(innerHash, block);
  return sha1(outer);
}

function headerValue(headers, name) {
  const want = String(name).toLowerCase();
  const list = Array.isArray(headers) ? headers : [];
  for (const pair of list) {
    if (!Array.isArray(pair) || pair.length < 2) continue;
    if (String(pair[0]).toLowerCase() === want) return String(pair[1]);
  }
  return "";
}

function httpGet(url) {
  const r = httpFetch({ url, method: "GET", headers: [], body: null });
  if (typeof r === "string") return { error: r };
  if (r && r.tag === "err") return { error: String(r.val) };
  const resp = r && r.val != null ? r.val : r;
  return {
    status: resp.status ?? 0,
    headers: resp.headers,
    body: new TextDecoder().decode(asBytes(resp.body)),
  };
}

function unixFromDateHeader(headers) {
  const raw = headerValue(headers, "date");
  if (!raw) return 0;
  const ms = Date.parse(raw);
  // Date.parse of a clock-less epoch can be 0; require a post-2001 timestamp.
  if (!Number.isFinite(ms) || ms < 1e12) return 0;
  return Math.floor(ms / 1000);
}

function unixFromTraceBody(body) {
  const m = /(?:^|\n)ts=([0-9]+(?:\.[0-9]+)?)/.exec(String(body ?? ""));
  if (!m) return 0;
  const n = Number(m[1]);
  if (!Number.isFinite(n) || n < 1e9) return 0;
  return Math.floor(n);
}

// Operators disable WASI clocks, so Date.now() is epoch 0 there. Local simulate
// still polyfills it. Take time from a public HTTP response instead, and do not
// echo unix in the WASM result (operators must agree on the JSON).
function unixNow() {
  const probes = [
    "https://www.cloudflare.com/cdn-cgi/trace",
    "https://cloudflare.com/",
  ];
  for (const url of probes) {
    const got = httpGet(url);
    if (got.error) continue;
    const fromTrace = unixFromTraceBody(got.body);
    if (fromTrace >= 1e9) return fromTrace;
    const fromHdr = unixFromDateHeader(got.headers);
    if (fromHdr >= 1e9) return fromHdr;
  }
  return 0;
}

function hotp(secretBytes, counter, digits) {
  const msg = new Uint8Array(8);
  const view = new DataView(msg.buffer);
  const hi = Math.floor(counter / 0x100000000);
  const lo = counter >>> 0;
  view.setUint32(0, hi, false);
  view.setUint32(4, lo, false);
  const mac = hmacSha1(secretBytes, msg);
  const offset = mac[19] & 0x0f;
  const bin =
    ((mac[offset] & 0x7f) << 24) |
    ((mac[offset + 1] & 0xff) << 16) |
    ((mac[offset + 2] & 0xff) << 8) |
    (mac[offset + 3] & 0xff);
  const mod = 10 ** digits;
  let s = String(bin % mod);
  while (s.length < digits) s = "0" + s;
  return s;
}

export function run(wasm_args) {
  try {
    const args = parseArgs(wasm_args);
    const secrets = loadSecrets();
    const secret = decodeBase32(secrets.totpSecretBase32);
    if (!secret || secret.length < 10) return fail("missing secrets");

    const digits = Number(secrets.digits ?? 6);
    const period = Number(secrets.period ?? 30);
    const skew = Number(secrets.skew ?? 1);
    if (!Number.isInteger(digits) || digits < 6 || digits > 8) return fail("digits");
    if (!Number.isInteger(period) || period <= 0) return fail("period");
    if (!Number.isInteger(skew) || skew < 0 || skew > 2) return fail("skew");

    const code = normalizeCode(args.code, digits);
    if (!code) return fail("code");

    const challenge = String(args.challenge ?? "");
    if (!challenge) return fail("challenge");

    const unix = unixNow();
    if (!unix) return fail("time");
    const counter = Math.floor(unix / period);
    let matched = false;
    for (let w = -skew; w <= skew; w++) {
      const c = counter + w;
      if (c < 0) continue;
      if (codesEqual(hotp(secret, c, digits), code)) matched = true;
    }
    if (!matched) return fail("totp");

    return JSON.stringify({ success: true, challenge });
  } catch (e) {
    return fail(String(e && e.message ? e.message : e));
  }
}