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
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));
}
}