Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

safe_actions_vectors.mjs

v0.1.0-build.85 Latest Commit ede38f5

Size
6.6 KB
Lines
98
Language
javascript
SHA-256
08924498c988…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1#!/usr/bin/env node
2// Writes tests/vectors/safe-actions-v1.json: Safe Action envelopes built and
3// signed by an implementation independent of the C core (this file, with
4// Node's own Ed25519), each with the verdict a conforming reader must reach.
5// The core test (tests/safe_actions_tests.cpp) checks every verdict, so the
6// format is pinned by two implementations that agree.
7//
8// node tools/safe_actions_vectors.mjs write
9// node tools/safe_actions_vectors.mjs --check fail if the file is stale
10import { createPrivateKey, createPublicKey, sign } from "node:crypto";
11import { readFileSync, writeFileSync } from "node:fs";
12
13const OUT = new URL("../tests/vectors/safe-actions-v1.json", import.meta.url);
14const CONTEXT = Buffer.concat([Buffer.from("CKey Safe Action v1"), Buffer.from([0])]);
15const PREFIX = "ckey-action:1:";
16const L = (1n << 252n) + 27742317777372353535851937790883648493n;
17
18function keyFromSeed(seedHex) {
19 const seed = Buffer.from(seedHex, "hex");
20 const priv = createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]), format: "der", type: "pkcs8" });
21 const pub = createPublicKey(priv).export({ format: "der", type: "spki" }).subarray(12);
22 return { priv, pub };
23}
24
25function u8str(s) { const b = Buffer.from(s, "latin1"); return Buffer.concat([Buffer.from([b.length]), b]); }
26function u64(n) { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; }
27
28function body(f) {
29 const params = Buffer.from(f.params ?? "", "utf8");
30 const len = Buffer.alloc(2); len.writeUInt16BE(params.length);
31 return Buffer.concat([Buffer.from([f.version ?? 1, f.scope]), u8str(f.issuer), u8str(f.app), u8str(f.action), u8str(f.key_id), u8str(f.request_id), u64(f.issued_at), u64(f.expires_at), len, params]);
32}
33
34function envelope(f, key) {
35 const b = body(f);
36 const sig = sign(null, Buffer.concat([CONTEXT, b]), key.priv);
37 return Buffer.concat([b, sig]);
38}
39
40const text = (bytes) => PREFIX + bytes.toString("base64url");
41
42// Keys: RFC 8032 test 1 and test 2 seeds.
43const ISSUER = keyFromSeed("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60");
44const OTHER = keyFromSeed("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb");
45const NOW = 1794000000; // a fixed "now" for every vector
46const base = {
47 scope: 0, issuer: "citeory.com", app: "com.viadere", action: "open.library", key_id: "2026-11",
48 request_id: "r_8f3K2pQ7xZ", issued_at: NOW - 30, expires_at: NOW + 90, params: "{\"item\":\"apa-7\"}"
49};
50
51const vectors = [];
52const add = (name, payload, expect, extra = {}) => vectors.push({ name, payload, public_key: (extra.key ?? ISSUER).pub.toString("hex"), now: extra.now ?? NOW, max_lifetime: extra.maxLifetime ?? 600, expect, ...(extra.fields ? { fields: extra.fields } : {}) });
53
54const fieldsOf = (f) => ({ scope: f.scope, issuer: f.issuer, app: f.app, action: f.action, key_id: f.key_id, request_id: f.request_id, issued_at: f.issued_at, expires_at: f.expires_at, params: f.params ?? "" });
55
56add("valid same-device action", text(envelope(base, ISSUER)), "ok", { fields: fieldsOf(base) });
57const cross = { ...base, scope: 1, action: "signin.request", request_id: "sess-41fd0a9be2c74", params: "" };
58add("valid cross-device sign-in, no params", text(envelope(cross, ISSUER)), "ok", { fields: fieldsOf(cross) });
59add("signed by a different key", text(envelope(base, OTHER)), "integrity");
60const tampered = envelope(base, ISSUER); tampered[2 + 1 + 11 + 1 + 11 + 1] ^= 0x01; // a byte of the action name
61add("action name changed after signing", text(tampered), "integrity");
62const swapped = Buffer.from(envelope(base, ISSUER)); const swapAt = 2 + 1 + 11 + 1; swapped.write("com.viaderf", swapAt, "latin1");
63add("receiving app changed after signing", text(swapped), "integrity");
64add("expired", text(envelope(base, ISSUER)), "expired", { now: NOW + 91 });
65add("valid for longer than the app allows", text(envelope({ ...base, expires_at: NOW + 3600 }, ISSUER)), "expired", { maxLifetime: 600 });
66add("issued in the future beyond clock tolerance", text(envelope({ ...base, issued_at: NOW + 300, expires_at: NOW + 400 }, ISSUER)), "expired");
67add("within clock tolerance", text(envelope({ ...base, issued_at: NOW + 100, expires_at: NOW + 200 }, ISSUER)), "ok", { fields: fieldsOf({ ...base, issued_at: NOW + 100, expires_at: NOW + 200 }) });
68add("expires before it is issued", text(envelope({ ...base, issued_at: NOW, expires_at: NOW - 1 }, ISSUER)), "expired");
69add("not a Safe Action", "https://citeory.com/", "invalid");
70add("wrong version prefix", "ckey-action:2:" + envelope(base, ISSUER).toString("base64url"), "invalid");
71add("not base64url", PREFIX + "abc+/def", "invalid");
72add("non-canonical base64url tail", PREFIX + envelope(base, ISSUER).toString("base64url").replace(/.$/, (c) => {
73 const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; return a[(a.indexOf(c) + 1) % 64];
74}), "invalid_or_integrity");
75add("upper-case issuer", text(envelope({ ...base, issuer: "Citeory.com" }, ISSUER)), "invalid");
76add("issuer without a dot", text(envelope({ ...base, issuer: "localhost" }, ISSUER)), "invalid");
77add("request ID too short", text(envelope({ ...base, request_id: "abc" }, ISSUER)), "invalid");
78add("unknown scope", text(envelope({ ...base, scope: 2 }, ISSUER)), "invalid");
79add("unsupported envelope version", text(envelope({ ...base, version: 2 }, ISSUER)), "invalid");
80const extra = Buffer.concat([envelope(base, ISSUER), Buffer.from([0])]);
81add("extra byte after the signature", text(extra), "invalid");
82const truncated = envelope(base, ISSUER).subarray(0, 40);
83add("truncated", text(truncated), "invalid");
84// S + L encodes the same signature value but is not canonical; RFC 8032 requires rejecting it.
85const env = Buffer.from(envelope(base, ISSUER));
86const sOff = env.length - 32;
87let s = 0n; for (let i = 31; i >= 0; i--) s = (s << 8n) | BigInt(env[sOff + i]);
88s += L;
89if (s < (1n << 256n)) { for (let i = 0; i < 32; i++) { env[sOff + i] = Number(s & 0xffn); s >>= 8n; } add("non-canonical signature scalar (S + L)", text(env), "integrity"); }
90
91const json = JSON.stringify({ schema: 1, description: "Safe Action envelopes from an independent implementation, with the verdict a conforming reader must reach. expect: ok, integrity (bad signature), expired (out of time or too long-lived), invalid (malformed).", vectors }, null, 2) + "\n";
92if (process.argv.includes("--check")) {
93 if (readFileSync(OUT, "utf8") !== json) { console.error("tests/vectors/safe-actions-v1.json is stale; run node tools/safe_actions_vectors.mjs"); process.exit(1); }
94 console.log(vectors.length + " Safe Action vectors current");
95} else {
96 writeFileSync(OUT, json);
97 console.log("wrote " + vectors.length + " Safe Action vectors");
98}