@forgezero/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/contracts/foundry.toml +9 -0
  4. package/contracts/src/ColdVault.sol +206 -0
  5. package/contracts/src/DepositFactory.sol +202 -0
  6. package/contracts/src/DepositProxy.sol +72 -0
  7. package/contracts/src/IERC20.sol +7 -0
  8. package/contracts/src/MockTokens.sol +32 -0
  9. package/contracts/src/SafeTransferLib.sol +31 -0
  10. package/contracts/test/Custody.t.sol +361 -0
  11. package/contracts/test/Vectors.t.sol +45 -0
  12. package/dist/audit.d.ts +265 -0
  13. package/dist/audit.js +291 -0
  14. package/dist/backup.d.ts +243 -0
  15. package/dist/backup.js +302 -0
  16. package/dist/calendar.d.ts +136 -0
  17. package/dist/calendar.js +129 -0
  18. package/dist/compliance.d.ts +172 -0
  19. package/dist/compliance.js +168 -0
  20. package/dist/finance/binance.d.ts +27 -0
  21. package/dist/finance/binance.js +452 -0
  22. package/dist/finance/chain-addresses.d.ts +130 -0
  23. package/dist/finance/chain-addresses.js +462 -0
  24. package/dist/finance/chain-deposits.d.ts +193 -0
  25. package/dist/finance/chain-deposits.js +596 -0
  26. package/dist/finance/chain-reconcile.d.ts +112 -0
  27. package/dist/finance/chain-reconcile.js +76 -0
  28. package/dist/finance/chain-withdrawals.d.ts +223 -0
  29. package/dist/finance/chain-withdrawals.js +631 -0
  30. package/dist/finance/chain.d.ts +116 -0
  31. package/dist/finance/chain.js +316 -0
  32. package/dist/finance/commission.d.ts +155 -0
  33. package/dist/finance/commission.js +419 -0
  34. package/dist/finance/custody.d.ts +68 -0
  35. package/dist/finance/custody.js +107 -0
  36. package/dist/finance/derive.d.ts +115 -0
  37. package/dist/finance/derive.js +116 -0
  38. package/dist/finance/discounts.d.ts +98 -0
  39. package/dist/finance/discounts.js +90 -0
  40. package/dist/finance/ledger.d.ts +221 -0
  41. package/dist/finance/ledger.js +308 -0
  42. package/dist/finance/market.d.ts +209 -0
  43. package/dist/finance/market.js +112 -0
  44. package/dist/finance/money.d.ts +118 -0
  45. package/dist/finance/money.js +176 -0
  46. package/dist/finance/rates.d.ts +178 -0
  47. package/dist/finance/rates.js +292 -0
  48. package/dist/finance/storage.d.ts +113 -0
  49. package/dist/finance/storage.js +226 -0
  50. package/dist/finance/tax.d.ts +132 -0
  51. package/dist/finance/tax.js +291 -0
  52. package/dist/finance/transfers.d.ts +153 -0
  53. package/dist/finance/transfers.js +292 -0
  54. package/dist/finance/venues.d.ts +190 -0
  55. package/dist/finance/venues.js +251 -0
  56. package/dist/identity.d.ts +115 -0
  57. package/dist/identity.js +111 -0
  58. package/dist/importers.d.ts +87 -0
  59. package/dist/importers.js +250 -0
  60. package/dist/jobs.d.ts +171 -0
  61. package/dist/jobs.js +250 -0
  62. package/dist/notify-templates.d.ts +11 -0
  63. package/dist/notify-templates.js +254 -0
  64. package/dist/notify.d.ts +172 -0
  65. package/dist/notify.js +122 -0
  66. package/dist/openssh.d.ts +36 -0
  67. package/dist/openssh.js +106 -0
  68. package/dist/otpauth.d.ts +57 -0
  69. package/dist/otpauth.js +223 -0
  70. package/dist/outbox.d.ts +234 -0
  71. package/dist/outbox.js +236 -0
  72. package/dist/passkey.d.ts +120 -0
  73. package/dist/passkey.js +105 -0
  74. package/dist/phrase.d.ts +87 -0
  75. package/dist/phrase.js +87 -0
  76. package/dist/pipeline.d.ts +137 -0
  77. package/dist/pipeline.js +121 -0
  78. package/dist/queue.d.ts +243 -0
  79. package/dist/queue.js +246 -0
  80. package/dist/schema-typebox.d.ts +24 -0
  81. package/dist/schema-typebox.js +201 -0
  82. package/dist/schema.d.ts +134 -0
  83. package/dist/schema.js +169 -0
  84. package/dist/serial.d.ts +54 -0
  85. package/dist/serial.js +40 -0
  86. package/dist/slip10.d.ts +37 -0
  87. package/dist/slip10.js +74 -0
  88. package/dist/snp.d.ts +115 -0
  89. package/dist/snp.js +109 -0
  90. package/dist/ssh-agent.d.ts +70 -0
  91. package/dist/ssh-agent.js +141 -0
  92. package/dist/ssh-cert.d.ts +73 -0
  93. package/dist/ssh-cert.js +111 -0
  94. package/dist/totp.d.ts +104 -0
  95. package/dist/totp.js +143 -0
  96. package/package.json +248 -0
@@ -0,0 +1,141 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/ssh-agent.ts
10
+ import { Socket } from "node:net";
11
+ import { createHash, hkdfSync } from "node:crypto";
12
+
13
+ class SshAgentError extends Error {
14
+ }
15
+ var SSH_AGENTC_REQUEST_IDENTITIES = 11;
16
+ var SSH_AGENT_IDENTITIES_ANSWER = 12;
17
+ var SSH_AGENTC_SIGN_REQUEST = 13;
18
+ var SSH_AGENT_SIGN_RESPONSE = 14;
19
+ var CUSTODY_CHALLENGE = Buffer.from("forgezero/custody/ssh-agent/v1", "utf8");
20
+ function readString(buffer, offset) {
21
+ const length = buffer.readUInt32BE(offset);
22
+ const start = offset + 4;
23
+ return [buffer.subarray(start, start + length), start + length];
24
+ }
25
+ function writeString(value) {
26
+ const length = Buffer.alloc(4);
27
+ length.writeUInt32BE(value.length);
28
+ return Buffer.concat([length, value]);
29
+ }
30
+ function frame(payload) {
31
+ const length = Buffer.alloc(4);
32
+ length.writeUInt32BE(payload.length);
33
+ return Buffer.concat([length, payload]);
34
+ }
35
+ var AGENT_TIMEOUT_MS = 3000;
36
+ async function request(socketPath, payload) {
37
+ return new Promise((resolve, reject) => {
38
+ const socket = new Socket;
39
+ const chunks = [];
40
+ let expected = null;
41
+ let settled = false;
42
+ const fail = (message) => {
43
+ if (settled)
44
+ return;
45
+ settled = true;
46
+ socket.destroy();
47
+ reject(new SshAgentError(message));
48
+ };
49
+ socket.setTimeout(AGENT_TIMEOUT_MS, () => fail("SSH_AGENT_TIMEOUT"));
50
+ socket.on("error", () => fail("SSH_AGENT_UNREACHABLE"));
51
+ socket.on("connect", () => socket.write(frame(payload)));
52
+ socket.on("data", (chunk) => {
53
+ chunks.push(chunk);
54
+ const all = Buffer.concat(chunks);
55
+ if (expected === null && all.length >= 4)
56
+ expected = all.readUInt32BE(0);
57
+ if (expected !== null && all.length >= expected + 4) {
58
+ if (settled)
59
+ return;
60
+ settled = true;
61
+ socket.end();
62
+ resolve(all.subarray(4, expected + 4));
63
+ }
64
+ });
65
+ socket.on("close", () => {
66
+ if (expected === null)
67
+ fail("SSH_AGENT_CLOSED_EARLY");
68
+ });
69
+ socket.connect(socketPath);
70
+ });
71
+ }
72
+ function agentSocket(explicit) {
73
+ const path = explicit ?? process.env.SSH_AUTH_SOCK;
74
+ if (!path)
75
+ throw new SshAgentError("SSH_AUTH_SOCK_NOT_SET");
76
+ return path;
77
+ }
78
+ async function listIdentities(socketPath) {
79
+ const response = await request(agentSocket(socketPath), Buffer.from([SSH_AGENTC_REQUEST_IDENTITIES]));
80
+ if (response[0] !== SSH_AGENT_IDENTITIES_ANSWER) {
81
+ throw new SshAgentError("SSH_AGENT_BAD_RESPONSE");
82
+ }
83
+ const count = response.readUInt32BE(1);
84
+ const identities = [];
85
+ let offset = 5;
86
+ for (let index = 0;index < count; index += 1) {
87
+ const [blob, afterBlob] = readString(response, offset);
88
+ const [comment, afterComment] = readString(response, afterBlob);
89
+ offset = afterComment;
90
+ const [type] = readString(blob, 0);
91
+ identities.push({
92
+ blob,
93
+ comment: comment.toString("utf8"),
94
+ type: type.toString("utf8"),
95
+ fingerprint: `SHA256:${createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`
96
+ });
97
+ }
98
+ return identities;
99
+ }
100
+ async function listCustodyIdentities(socketPath) {
101
+ return (await listIdentities(socketPath)).filter((id) => id.type === "ssh-ed25519");
102
+ }
103
+ async function sign(blob, data, socketPath) {
104
+ const payload = Buffer.concat([
105
+ Buffer.from([SSH_AGENTC_SIGN_REQUEST]),
106
+ writeString(blob),
107
+ writeString(data),
108
+ Buffer.alloc(4)
109
+ ]);
110
+ const response = await request(agentSocket(socketPath), payload);
111
+ if (response[0] !== SSH_AGENT_SIGN_RESPONSE) {
112
+ throw new SshAgentError("SSH_AGENT_SIGN_REFUSED");
113
+ }
114
+ const [signature] = readString(response, 1);
115
+ return signature;
116
+ }
117
+ async function deriveCustodyKey(identity, socketPath) {
118
+ if (identity.type !== "ssh-ed25519") {
119
+ throw new SshAgentError("SSH_KEY_TYPE_UNSUPPORTED");
120
+ }
121
+ const signature = await sign(identity.blob, CUSTODY_CHALLENGE, socketPath);
122
+ if (signature.length < 32)
123
+ throw new SshAgentError("SSH_AGENT_SIGNATURE_TOO_SHORT");
124
+ return new Uint8Array(hkdfSync("sha256", signature, identity.blob, Buffer.from("forgezero/custody/ssh-key/v1", "utf8"), 32));
125
+ }
126
+ async function assertDeterministic(identity, socketPath) {
127
+ const first = await deriveCustodyKey(identity, socketPath);
128
+ const second = await deriveCustodyKey(identity, socketPath);
129
+ if (Buffer.compare(Buffer.from(first), Buffer.from(second)) !== 0) {
130
+ throw new SshAgentError("SSH_AGENT_NOT_DETERMINISTIC");
131
+ }
132
+ return first;
133
+ }
134
+ export {
135
+ listIdentities,
136
+ listCustodyIdentities,
137
+ deriveCustodyKey,
138
+ assertDeterministic,
139
+ SshAgentError,
140
+ AGENT_TIMEOUT_MS
141
+ };
@@ -0,0 +1,73 @@
1
+ export declare class SshCertError extends Error {
2
+ readonly code: 'BAD_KEY' | 'BAD_VALIDITY' | 'BAD_PRINCIPAL' | 'NO_PRINCIPALS';
3
+ constructor(code: 'BAD_KEY' | 'BAD_VALIDITY' | 'BAD_PRINCIPAL' | 'NO_PRINCIPALS', message: string);
4
+ }
5
+ export declare const CERT_TYPE_USER = 1;
6
+ export declare const CERT_TYPE_HOST = 2;
7
+ /**
8
+ * What a user certificate is normally allowed to do.
9
+ *
10
+ * Deliberately NOT `permit-port-forwarding` or `permit-agent-forwarding`: both
11
+ * turn a session into a route onward, and an expiring certificate that can open
12
+ * a tunnel through a bastion has given away rather more than a login.
13
+ */
14
+ export declare const DEFAULT_EXTENSIONS: Record<string, string>;
15
+ export interface CertificateRequest {
16
+ /** The ed25519 public key being certified, 32 bytes. */
17
+ publicKey: Uint8Array;
18
+ /** 1 for a user certificate, 2 for a host certificate. */
19
+ type?: number;
20
+ /** What appears in the sshd log. Make it identify a person or a machine. */
21
+ keyId: string;
22
+ /** Usernames (user certs) or hostnames (host certs). Never empty — see below. */
23
+ principals: readonly string[];
24
+ validAfterSec: number;
25
+ validBeforeSec: number;
26
+ serial?: bigint;
27
+ extensions?: Record<string, string>;
28
+ /** `force-command`, `source-address`. Unknown ones make sshd REFUSE the cert. */
29
+ criticalOptions?: Record<string, string>;
30
+ }
31
+ /**
32
+ * A signer, not a secret key.
33
+ *
34
+ * The CA key that matters lives in the vault as a DERIVED key: it is reached
35
+ * through a call that uses it and wipes it, and the private half never exists
36
+ * outside that call. A signature function is the only shape both that and a
37
+ * plain local key can satisfy.
38
+ *
39
+ * Taking raw secret bytes here would have forced the vault to hand them out to
40
+ * use them, which is the one thing derived keys exist to prevent.
41
+ */
42
+ export interface CertificateAuthority {
43
+ publicKey: Uint8Array;
44
+ /** Signs the certificate body. Returns 64 bytes. */
45
+ sign: (message: Uint8Array) => Uint8Array;
46
+ }
47
+ /**
48
+ * A CA backed by a local secret key, for tests and for a laptop.
49
+ *
50
+ * Deliberately a separate function rather than an overload: reading
51
+ * `caFromSecretKey` at a call site tells you the key is in this process, which
52
+ * is exactly the thing worth noticing in a review.
53
+ */
54
+ export declare const caFromSecretKey: (secretKey: Uint8Array) => CertificateAuthority;
55
+ /**
56
+ * Sign one certificate.
57
+ *
58
+ * The validity window is required and checked. A certificate with no expiry is
59
+ * an `authorized_keys` line with extra steps, and the entire reason to run a CA
60
+ * is that access ends by itself.
61
+ */
62
+ export declare function signCertificate(request: CertificateRequest, ca: CertificateAuthority): string;
63
+ /** The CA line for `TrustedUserCAKeys`, which is the one thing a host must hold. */
64
+ export declare const caPublicKeyLine: (publicKey: Uint8Array, comment?: string) => string;
65
+ /** The fingerprint `ssh-keygen -L` prints for the CA. */
66
+ export declare const caFingerprint: (publicKey: Uint8Array) => string;
67
+ /**
68
+ * Is this certificate usable right now?
69
+ *
70
+ * Both bounds, because a clock that has drifted backwards makes a
71
+ * not-yet-valid certificate look like a broken CA rather than a wrong clock.
72
+ */
73
+ export declare const isCurrentlyValid: (cert: Pick<CertificateRequest, "validAfterSec" | "validBeforeSec">, nowSec?: number) => boolean;
@@ -0,0 +1,111 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/ssh-cert.ts
10
+ import { ed25519 } from "@noble/curves/ed25519.js";
11
+ import { sha256 } from "@noble/hashes/sha2.js";
12
+ import { toBase64Url } from "@forgezero/access/security";
13
+ var CERT_ALGORITHM = "ssh-ed25519-cert-v01@openssh.com";
14
+ var KEY_ALGORITHM = "ssh-ed25519";
15
+ var ENCODER = new TextEncoder;
16
+
17
+ class SshCertError extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ this.name = "SshCertError";
23
+ }
24
+ }
25
+ var u32 = (value) => {
26
+ const out = new Uint8Array(4);
27
+ new DataView(out.buffer).setUint32(0, value, false);
28
+ return out;
29
+ };
30
+ var u64 = (value) => {
31
+ const out = new Uint8Array(8);
32
+ new DataView(out.buffer).setBigUint64(0, value, false);
33
+ return out;
34
+ };
35
+ var concat = (...parts) => {
36
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
37
+ const out = new Uint8Array(total);
38
+ let offset = 0;
39
+ for (const part of parts) {
40
+ out.set(part, offset);
41
+ offset += part.length;
42
+ }
43
+ return out;
44
+ };
45
+ var sshString = (value) => {
46
+ const bytes = typeof value === "string" ? ENCODER.encode(value) : value;
47
+ return concat(u32(bytes.length), bytes);
48
+ };
49
+ var sshStringList = (values) => sshString(concat(...values.map((value) => sshString(value))));
50
+ var sshPairs = (pairs) => sshString(concat(...Object.keys(pairs).sort().map((name) => concat(sshString(name), sshString(pairs[name] === "" ? new Uint8Array(0) : sshString(pairs[name]))))));
51
+ var base64 = (bytes) => {
52
+ const standard = toBase64Url(bytes).replaceAll("-", "+").replaceAll("_", "/");
53
+ return standard + "=".repeat((4 - standard.length % 4) % 4);
54
+ };
55
+ var CERT_TYPE_USER = 1;
56
+ var CERT_TYPE_HOST = 2;
57
+ var DEFAULT_EXTENSIONS = {
58
+ "permit-pty": "",
59
+ "permit-user-rc": ""
60
+ };
61
+ var caFromSecretKey = (secretKey) => ({
62
+ publicKey: ed25519.getPublicKey(secretKey.slice(0, 32)),
63
+ sign: (message) => ed25519.sign(message, secretKey.slice(0, 32))
64
+ });
65
+ function signCertificate(request, ca) {
66
+ if (request.publicKey.length !== 32) {
67
+ throw new SshCertError("BAD_KEY", "An ed25519 public key is 32 bytes.");
68
+ }
69
+ if (ca.publicKey.length !== 32) {
70
+ throw new SshCertError("BAD_KEY", "The CA's ed25519 public key is 32 bytes.");
71
+ }
72
+ if (!Number.isInteger(request.validAfterSec) || !Number.isInteger(request.validBeforeSec)) {
73
+ throw new SshCertError("BAD_VALIDITY", "Validity bounds are whole seconds.");
74
+ }
75
+ if (request.validBeforeSec <= request.validAfterSec) {
76
+ throw new SshCertError("BAD_VALIDITY", "A certificate must be valid for a positive interval.");
77
+ }
78
+ if (request.principals.length === 0) {
79
+ throw new SshCertError("NO_PRINCIPALS", "A certificate with no principals is valid for EVERY user. Name them.");
80
+ }
81
+ for (const principal of request.principals) {
82
+ if (!principal.trim() || /[\s,]/.test(principal)) {
83
+ throw new SshCertError("BAD_PRINCIPAL", `"${principal}" is not a usable principal.`);
84
+ }
85
+ }
86
+ const nonce = crypto.getRandomValues(new Uint8Array(32));
87
+ const body = concat(sshString(CERT_ALGORITHM), sshString(nonce), sshString(request.publicKey), u64(request.serial ?? 0n), u32(request.type ?? CERT_TYPE_USER), sshString(request.keyId), sshStringList(request.principals), u64(BigInt(request.validAfterSec)), u64(BigInt(request.validBeforeSec)), sshPairs(request.criticalOptions ?? {}), sshPairs(request.extensions ?? DEFAULT_EXTENSIONS), sshString(new Uint8Array(0)), sshString(concat(sshString(KEY_ALGORITHM), sshString(ca.publicKey))));
88
+ const signature = ca.sign(body);
89
+ if (signature.length !== 64) {
90
+ throw new SshCertError("BAD_KEY", "An ed25519 signature is 64 bytes.");
91
+ }
92
+ const blob = concat(body, sshString(concat(sshString(KEY_ALGORITHM), sshString(signature))));
93
+ return `${CERT_ALGORITHM} ${base64(blob)}`;
94
+ }
95
+ var caPublicKeyLine = (publicKey, comment = "") => {
96
+ const line = `${KEY_ALGORITHM} ${base64(concat(sshString(KEY_ALGORITHM), sshString(publicKey)))}`;
97
+ return comment.trim() ? `${line} ${comment.trim()}` : line;
98
+ };
99
+ var caFingerprint = (publicKey) => `SHA256:${base64(sha256(concat(sshString(KEY_ALGORITHM), sshString(publicKey)))).replace(/=+$/, "")}`;
100
+ var isCurrentlyValid = (cert, nowSec = Math.floor(Date.now() / 1000)) => nowSec >= cert.validAfterSec && nowSec < cert.validBeforeSec;
101
+ export {
102
+ signCertificate,
103
+ isCurrentlyValid,
104
+ caPublicKeyLine,
105
+ caFromSecretKey,
106
+ caFingerprint,
107
+ SshCertError,
108
+ DEFAULT_EXTENSIONS,
109
+ CERT_TYPE_USER,
110
+ CERT_TYPE_HOST
111
+ };
package/dist/totp.d.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * TOTP — RFC 6238, on the HMAC that already exists.
3
+ *
4
+ * Almost none of this is new. `@forgezero/access/security` already has the
5
+ * HMAC, the constant-time compare and the CSPRNG; what was missing is base32
6
+ * (because authenticator apps read that and nothing else) and the counter
7
+ * window that tolerates a phone whose clock is a few seconds out.
8
+ *
9
+ * ## SHA-1, and why that is not a mistake here
10
+ *
11
+ * Every authenticator app implements SHA-1 and only some implement SHA-256, so
12
+ * a SHA-256 secret is one a user cannot enrol with the app they already have.
13
+ * The collision weaknesses in SHA-1 do not apply to HMAC — HMAC-SHA1 has no
14
+ * practical break — and the code is six digits from a shared secret either way.
15
+ * Choosing SHA-256 here would trade a real usability failure for no security
16
+ * gain, so the algorithm is a parameter with SHA-1 as the default.
17
+ *
18
+ * ## The window is asymmetric, deliberately
19
+ *
20
+ * One step back and one forward. Backward tolerance matters most: a user reads
21
+ * a code, types it, and submits a few seconds later, so the code that was
22
+ * current when they looked has often just expired. Forward tolerance only
23
+ * covers a fast clock. Widening either multiplies the codes a guesser can hit,
24
+ * which is why it is one step and not three.
25
+ *
26
+ * ## Replay is NOT handled here
27
+ *
28
+ * A code stays valid for its whole step, so an intercepted one works again
29
+ * within thirty seconds. Preventing that needs storage — the last accepted
30
+ * counter per user — which belongs with the session, not in a pure function.
31
+ * `assertNotReplayed` is provided so the caller cannot forget, and it takes the
32
+ * previous counter rather than owning any state.
33
+ */
34
+ export declare class TotpError extends Error {
35
+ readonly code: 'BAD_SECRET' | 'BAD_CODE' | 'REPLAYED' | 'BAD_DIGITS' | 'OTPAUTH_MALFORMED' | 'OTPAUTH_UNSUPPORTED' | 'OTPAUTH_NO_SECRET';
36
+ constructor(code: 'BAD_SECRET' | 'BAD_CODE' | 'REPLAYED' | 'BAD_DIGITS' | 'OTPAUTH_MALFORMED' | 'OTPAUTH_UNSUPPORTED' | 'OTPAUTH_NO_SECRET', message: string);
37
+ }
38
+ export declare function toBase32(bytes: Uint8Array): string;
39
+ export declare function fromBase32(text: string): Uint8Array;
40
+ /**
41
+ * A new secret.
42
+ *
43
+ * 20 bytes — the RFC 4226 recommendation and what every authenticator expects.
44
+ * Longer is not stronger in any way that matters: the output is still six
45
+ * digits, so the search space is the code, not the key.
46
+ */
47
+ export declare function generateSecret(bytes?: number): string;
48
+ export declare const DEFAULT_STEP_SECONDS = 30;
49
+ export declare const DEFAULT_DIGITS = 6;
50
+ export interface TotpOptions {
51
+ stepSeconds?: number;
52
+ digits?: number;
53
+ algorithm?: 'SHA-1' | 'SHA-256' | 'SHA-512';
54
+ }
55
+ /** Which step a timestamp falls in. The counter HMAC'd to make the code. */
56
+ export declare const counterAt: (atMs: number, stepSeconds?: number) => number;
57
+ /** The code for one counter. */
58
+ export declare function codeFor(secret: string, counter: number, options?: TotpOptions): Promise<string>;
59
+ export declare const codeAt: (secret: string, atMs: number, options?: TotpOptions) => Promise<string>;
60
+ export interface VerifyOptions extends TotpOptions {
61
+ /** Steps either side to accept. One, and see the note at the top. */
62
+ window?: number;
63
+ atMs?: number;
64
+ /**
65
+ * The last counter this user successfully used.
66
+ *
67
+ * Supplied by the caller, because replay defence needs storage and this is a
68
+ * pure function. Omitting it means replay is NOT prevented — which is a
69
+ * decision, and one the caller should make knowingly.
70
+ */
71
+ lastCounter?: number;
72
+ }
73
+ export interface VerifyResult {
74
+ ok: boolean;
75
+ /** The counter that matched. Store it as `lastCounter` for the next check. */
76
+ counter?: number;
77
+ reason?: TotpError['code'];
78
+ }
79
+ /**
80
+ * Check a code.
81
+ *
82
+ * Every candidate is compared in constant time and the loop does NOT exit
83
+ * early on a match — an early return leaks, through timing, which step matched,
84
+ * and that narrows a guess to a known offset.
85
+ */
86
+ export declare function verifyCode(secret: string, code: string, options?: VerifyOptions): Promise<VerifyResult>;
87
+ /** Throwing form, for a caller that treats a bad code as an exception. */
88
+ export declare function assertCode(secret: string, code: string, options?: VerifyOptions): Promise<number>;
89
+ /**
90
+ * The `otpauth://` URI an authenticator app scans.
91
+ *
92
+ * The issuer appears TWICE — once as a label prefix and once as a parameter —
93
+ * because older apps read only the label and newer ones only the parameter.
94
+ * Omitting either means the account shows up in somebody's app as a bare email
95
+ * with no indication of what it is for.
96
+ */
97
+ export declare function enrolmentUri(args: {
98
+ secret: string;
99
+ account: string;
100
+ issuer: string;
101
+ options?: TotpOptions;
102
+ }): string;
103
+ /** Seconds until the current code expires. For a countdown on the screen. */
104
+ export declare const secondsRemaining: (atMs?: number, stepSeconds?: number) => number;
package/dist/totp.js ADDED
@@ -0,0 +1,143 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/totp.ts
10
+ import { timingSafeEqual } from "@forgezero/access/security";
11
+
12
+ class TotpError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = "TotpError";
18
+ }
19
+ }
20
+ var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
21
+ function toBase32(bytes) {
22
+ let bits = 0;
23
+ let value = 0;
24
+ let output = "";
25
+ for (const byte of bytes) {
26
+ value = value << 8 | byte;
27
+ bits += 8;
28
+ while (bits >= 5) {
29
+ output += ALPHABET[value >>> bits - 5 & 31];
30
+ bits -= 5;
31
+ }
32
+ }
33
+ if (bits > 0)
34
+ output += ALPHABET[value << 5 - bits & 31];
35
+ return output;
36
+ }
37
+ function fromBase32(text) {
38
+ const cleaned = text.toUpperCase().replace(/[\s=-]/g, "");
39
+ if (cleaned.length === 0)
40
+ throw new TotpError("BAD_SECRET", "The secret is empty.");
41
+ let bits = 0;
42
+ let value = 0;
43
+ const bytes = [];
44
+ for (const character of cleaned) {
45
+ const index = ALPHABET.indexOf(character);
46
+ if (index === -1) {
47
+ throw new TotpError("BAD_SECRET", `"${character}" is not a base32 character.`);
48
+ }
49
+ value = value << 5 | index;
50
+ bits += 5;
51
+ if (bits >= 8) {
52
+ bytes.push(value >>> bits - 8 & 255);
53
+ bits -= 8;
54
+ }
55
+ }
56
+ return new Uint8Array(bytes);
57
+ }
58
+ function generateSecret(bytes = 20) {
59
+ const buffer = new Uint8Array(bytes);
60
+ crypto.getRandomValues(buffer);
61
+ return toBase32(buffer);
62
+ }
63
+ var DEFAULT_STEP_SECONDS = 30;
64
+ var DEFAULT_DIGITS = 6;
65
+ var counterAt = (atMs, stepSeconds = DEFAULT_STEP_SECONDS) => Math.floor(atMs / 1000 / stepSeconds);
66
+ async function hmac(secret, counter, algorithm) {
67
+ const message = new Uint8Array(8);
68
+ let remaining = counter;
69
+ for (let index = 7;index >= 0; index -= 1) {
70
+ message[index] = remaining & 255;
71
+ remaining = Math.floor(remaining / 256);
72
+ }
73
+ const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: algorithm }, false, ["sign"]);
74
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, message));
75
+ }
76
+ async function codeFor(secret, counter, options = {}) {
77
+ const digits = options.digits ?? DEFAULT_DIGITS;
78
+ if (digits < 6 || digits > 8) {
79
+ throw new TotpError("BAD_DIGITS", "A TOTP code is between six and eight digits.");
80
+ }
81
+ const mac = await hmac(fromBase32(secret), counter, options.algorithm ?? "SHA-1");
82
+ const offset = mac[mac.length - 1] & 15;
83
+ const binary = (mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255;
84
+ return String(binary % 10 ** digits).padStart(digits, "0");
85
+ }
86
+ var codeAt = (secret, atMs, options = {}) => codeFor(secret, counterAt(atMs, options.stepSeconds ?? DEFAULT_STEP_SECONDS), options);
87
+ async function verifyCode(secret, code, options = {}) {
88
+ const digits = options.digits ?? DEFAULT_DIGITS;
89
+ const cleaned = code.replace(/\s/g, "");
90
+ if (!new RegExp(`^\\d{${digits}}$`).test(cleaned)) {
91
+ return { ok: false, reason: "BAD_CODE" };
92
+ }
93
+ const step = options.stepSeconds ?? DEFAULT_STEP_SECONDS;
94
+ const window = options.window ?? 1;
95
+ const current = counterAt(options.atMs ?? Date.now(), step);
96
+ let matched;
97
+ for (let drift = -window;drift <= window; drift += 1) {
98
+ const counter = current + drift;
99
+ const expected = await codeFor(secret, counter, options);
100
+ if (timingSafeEqual(expected, cleaned))
101
+ matched = counter;
102
+ }
103
+ if (matched === undefined)
104
+ return { ok: false, reason: "BAD_CODE" };
105
+ if (options.lastCounter !== undefined && matched <= options.lastCounter) {
106
+ return { ok: false, counter: matched, reason: "REPLAYED" };
107
+ }
108
+ return { ok: true, counter: matched };
109
+ }
110
+ async function assertCode(secret, code, options = {}) {
111
+ const result = await verifyCode(secret, code, options);
112
+ if (!result.ok) {
113
+ throw new TotpError(result.reason ?? "BAD_CODE", result.reason === "REPLAYED" ? "That code has already been used. Wait for the next one." : "That code is not right. Check your authenticator and try again.");
114
+ }
115
+ return result.counter;
116
+ }
117
+ function enrolmentUri(args) {
118
+ const label = `${encodeURIComponent(args.issuer)}:${encodeURIComponent(args.account)}`;
119
+ const parameters = new URLSearchParams({
120
+ secret: args.secret,
121
+ issuer: args.issuer,
122
+ algorithm: (args.options?.algorithm ?? "SHA-1").replace("-", ""),
123
+ digits: String(args.options?.digits ?? DEFAULT_DIGITS),
124
+ period: String(args.options?.stepSeconds ?? DEFAULT_STEP_SECONDS)
125
+ });
126
+ return `otpauth://totp/${label}?${parameters.toString()}`;
127
+ }
128
+ var secondsRemaining = (atMs = Date.now(), stepSeconds = DEFAULT_STEP_SECONDS) => stepSeconds - Math.floor(atMs / 1000) % stepSeconds;
129
+ export {
130
+ verifyCode,
131
+ toBase32,
132
+ secondsRemaining,
133
+ generateSecret,
134
+ fromBase32,
135
+ enrolmentUri,
136
+ counterAt,
137
+ codeFor,
138
+ codeAt,
139
+ assertCode,
140
+ TotpError,
141
+ DEFAULT_STEP_SECONDS,
142
+ DEFAULT_DIGITS
143
+ };