@twinsearth/w2m-dsh-plugin 1.1.2 → 1.1.14
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +85 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +8 -3
- package/plugin.json +1 -1
- package/src/plugin/fleet-target.mjs +23 -2
- package/src/plugin/join-degradation.mjs +34 -0
- package/src/plugin/tools.mjs +17 -7
- package/src/relay/admission.mjs +239 -0
- package/src/relay/join-challenge.mjs +92 -0
- package/src/relay/server.mjs +70 -0
- package/src/relay/state.mjs +46 -0
- package/src/util/account-identity.mjs +75 -0
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exchange that lets an UNPAIRED machine prove an account it cannot authenticate as a device.
|
|
3
|
+
*
|
|
4
|
+
* ## The shape
|
|
5
|
+
*
|
|
6
|
+
* 1. the machine asks for a challenge, naming the account fingerprint;
|
|
7
|
+
* 2. the relay answers with a one-time nonce and an expiry;
|
|
8
|
+
* 3. the machine signs \`challenge|nonce|fingerprint|machine_id\` with the account's derived private key;
|
|
9
|
+
* 4. the relay verifies with the public key registered when that account's first machine paired.
|
|
10
|
+
*
|
|
11
|
+
* ## The three properties, and why each is a test
|
|
12
|
+
*
|
|
13
|
+
* - **One-time.** A challenge is consumed by the first attempt, success or failure, because a retryable nonce is a replay
|
|
14
|
+
* window. Consuming on failure means an attacker who guessed wrong must fetch a new challenge -- which is rate limited.
|
|
15
|
+
* - **Expiring.** Five minutes, on the relay's clock. A nonce that lives for ever is a signature that can be reused at
|
|
16
|
+
* leisure.
|
|
17
|
+
* - **Rate limited per fingerprint.** Not per address: the thing being protected is a fingerprint, and an attacker with
|
|
18
|
+
* many addresses must not get many guesses.
|
|
19
|
+
*
|
|
20
|
+
* The signature payload binds the machine_id as well as the fingerprint: a valid signature harvested for one machine must
|
|
21
|
+
* not admit another.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
24
|
+
import { verifyChallenge } from '../util/account-identity.mjs';
|
|
25
|
+
|
|
26
|
+
export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
27
|
+
export const CHALLENGE_MAX_PER_MINUTE = 5;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{now?: () => number, ttlMs?: number, maxPerMinute?: number,
|
|
31
|
+
* publicKeyFor: (fingerprint: string) => (string|null)}} options
|
|
32
|
+
*/
|
|
33
|
+
export function createJoinChallenges(options = {}) {
|
|
34
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
35
|
+
const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : CHALLENGE_TTL_MS;
|
|
36
|
+
const maxPerMinute = Number.isInteger(options.maxPerMinute) ? options.maxPerMinute : CHALLENGE_MAX_PER_MINUTE;
|
|
37
|
+
const publicKeyFor = typeof options.publicKeyFor === 'function' ? options.publicKeyFor : () => null;
|
|
38
|
+
|
|
39
|
+
/** challenge_id -> { fingerprint, nonce, expires_at_ms } */
|
|
40
|
+
const open = new Map();
|
|
41
|
+
/** fingerprint -> [issue times] */
|
|
42
|
+
const issued = new Map();
|
|
43
|
+
|
|
44
|
+
const prune = () => {
|
|
45
|
+
const t = now();
|
|
46
|
+
for (const [id, c] of open) if (c.expires_at_ms <= t) open.delete(id);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The exact bytes signed. Exported so the client and the tests cannot drift from the relay. */
|
|
50
|
+
const signingPayload = (challenge) => [challenge.challenge_id, challenge.nonce, challenge.fingerprint, challenge.machine_id].join('|');
|
|
51
|
+
|
|
52
|
+
function issue(fingerprint, machineId = '') {
|
|
53
|
+
if (typeof fingerprint !== 'string' || fingerprint === '') return { ok: false, code: 'BAD_FINGERPRINT' };
|
|
54
|
+
if (publicKeyFor(fingerprint) === null) return { ok: false, code: 'FINGERPRINT_UNREGISTERED' };
|
|
55
|
+
prune();
|
|
56
|
+
const recent = (issued.get(fingerprint) ?? []).filter((at) => now() - at < 60_000);
|
|
57
|
+
if (recent.length >= maxPerMinute) { issued.set(fingerprint, recent); return { ok: false, code: 'RATE_LIMITED' }; }
|
|
58
|
+
recent.push(now());
|
|
59
|
+
issued.set(fingerprint, recent);
|
|
60
|
+
const challenge_id = createHash('sha256').update(fingerprint).update(String(randomBytes(16).toString('hex'))).update(String(now())).digest('hex').slice(0, 32);
|
|
61
|
+
const challenge = {
|
|
62
|
+
challenge_id, nonce: randomBytes(16).toString('hex'), fingerprint,
|
|
63
|
+
machine_id: typeof machineId === 'string' ? machineId : '', expires_at_ms: now() + ttlMs,
|
|
64
|
+
};
|
|
65
|
+
open.set(challenge_id, challenge);
|
|
66
|
+
return { ok: true, challenge_id, nonce: challenge.nonce, expires_at_ms: challenge.expires_at_ms };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verify a signed challenge. Consumes the challenge on ANY attempt.
|
|
71
|
+
*
|
|
72
|
+
* @param {{challenge_id: string, machine_id: string, signature: string}} request
|
|
73
|
+
* @returns {{ok: true, fingerprint: string} | {ok: false, code: string}}
|
|
74
|
+
*/
|
|
75
|
+
function consume(request) {
|
|
76
|
+
prune();
|
|
77
|
+
const challenge = open.get(request?.challenge_id);
|
|
78
|
+
if (challenge === undefined) return { ok: false, code: 'CHALLENGE_INVALID' };
|
|
79
|
+
open.delete(challenge.challenge_id); // one attempt, success or not
|
|
80
|
+
if (challenge.expires_at_ms <= now()) return { ok: false, code: 'CHALLENGE_EXPIRED' };
|
|
81
|
+
if (typeof request.machine_id !== 'string' || request.machine_id === '') return { ok: false, code: 'BAD_REQUEST' };
|
|
82
|
+
// The machine that signs must be the machine that asked: issuing for one id and signing for another is a forgery.
|
|
83
|
+
if (challenge.machine_id !== '' && challenge.machine_id !== request.machine_id) return { ok: false, code: 'MACHINE_MISMATCH' };
|
|
84
|
+
const pem = publicKeyFor(challenge.fingerprint);
|
|
85
|
+
if (pem === null) return { ok: false, code: 'FINGERPRINT_UNREGISTERED' };
|
|
86
|
+
const payload = signingPayload({ ...challenge, machine_id: request.machine_id });
|
|
87
|
+
if (verifyChallenge(pem, payload, request.signature) !== true) return { ok: false, code: 'SIGNATURE_INVALID' };
|
|
88
|
+
return { ok: true, fingerprint: challenge.fingerprint };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { issue, consume, signingPayload, _open: open };
|
|
92
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
RabbitState,
|
|
23
23
|
rfc3339,
|
|
24
24
|
} from './state.mjs';
|
|
25
|
+
import { createJoinChallenges } from './join-challenge.mjs';
|
|
26
|
+
import { createAdmission } from './admission.mjs';
|
|
25
27
|
import { Persistence } from './persistence.mjs';
|
|
26
28
|
import { PeerRegistry } from './peers.mjs';
|
|
27
29
|
import { aggregateTask, buildReport } from './report.mjs';
|
|
@@ -733,6 +735,25 @@ export class RelayServer {
|
|
|
733
735
|
return sendJson(res, 200, result);
|
|
734
736
|
}
|
|
735
737
|
|
|
738
|
+
/* ---- v1.1.6: reachable WITHOUT a device token ---- */
|
|
739
|
+
this.joinChallenges ??= createJoinChallenges({
|
|
740
|
+
now: () => this.state.nowMs(),
|
|
741
|
+
publicKeyFor: (fingerprint) => this.state.accountKeyFor(fingerprint),
|
|
742
|
+
});
|
|
743
|
+
if (method === 'GET' && path === '/v1/fleet/admission/challenge') {
|
|
744
|
+
const issued = this.joinChallenges.issue(url.searchParams.get('fingerprint') ?? '', url.searchParams.get('machine_id') ?? '');
|
|
745
|
+
if (issued.ok !== true) throw new ProtocolError('BAD_REQUEST', 'challenge refused: ' + issued.code, { code: issued.code });
|
|
746
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...issued });
|
|
747
|
+
}
|
|
748
|
+
if (method === 'POST' && path === '/v1/fleet/admission/request') {
|
|
749
|
+
const body = takeJson();
|
|
750
|
+
this.admission ??= createAdmission({ now: () => this.state.nowMs() });
|
|
751
|
+
const proof = this.joinChallenges.consume({ challenge_id: body.challenge_id, machine_id: body.machine_id, signature: body.signature });
|
|
752
|
+
if (proof.ok !== true) throw new ProtocolError('UNAUTHORIZED', 'join proof refused: ' + proof.code, { code: proof.code });
|
|
753
|
+
const result = this.admission.requestAdmission({ machine_id: body.machine_id, machine_name: body.machine_name ?? null, account_fingerprint: proof.fingerprint });
|
|
754
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
|
|
755
|
+
}
|
|
756
|
+
|
|
736
757
|
/* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
|
|
737
758
|
const token = bearerToken(req);
|
|
738
759
|
const device = token ? this.state.authenticate(token) : null;
|
|
@@ -889,6 +910,11 @@ export class RelayServer {
|
|
|
889
910
|
// v0.3.9: P2P candidate rendezvous. `/announce` is checked before the read route
|
|
890
911
|
// so that a machine literally named "announce" cannot shadow it.
|
|
891
912
|
const peerReadMatch = /^\/v1\/peer\/([^/]+)$/.exec(path);
|
|
913
|
+
/* ---- v1.1.6 admission ---- */
|
|
914
|
+
const admissionDecideMatch = /^\/v1\/fleet\/admission\/([^/]+)\/decide$/.exec(path);
|
|
915
|
+
const admissionOneMatch = /^\/v1\/fleet\/admission\/([^/]+)$/.exec(path);
|
|
916
|
+
const memberRemoveMatch = /^\/v1\/fleet\/members\/([^/]+)\/remove$/.exec(path);
|
|
917
|
+
const removalVoteMatch = /^\/v1\/fleet\/removals\/([^/]+)\/vote$/.exec(path);
|
|
892
918
|
|
|
893
919
|
/* ---- v0.3.9 P2P signalling ---- */
|
|
894
920
|
if (method === 'POST' && path === '/v1/peer/announce') {
|
|
@@ -967,6 +993,50 @@ export class RelayServer {
|
|
|
967
993
|
});
|
|
968
994
|
}
|
|
969
995
|
|
|
996
|
+
|
|
997
|
+
/* ---- v1.1.6 admission: the caller is always `device.machine_id`, never a body field ---- */
|
|
998
|
+
this.admission ??= createAdmission({ now: () => this.state.nowMs() });
|
|
999
|
+
|
|
1000
|
+
if (method === 'GET' && path === '/v1/fleet/admission/pending') {
|
|
1001
|
+
return sendJson(res, 200, {
|
|
1002
|
+
protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(),
|
|
1003
|
+
pending: this.admission.pendingFor(),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (method === 'POST' && admissionDecideMatch) {
|
|
1008
|
+
const body = takeJson();
|
|
1009
|
+
const result = this.admission.decide(decodeURIComponent(admissionDecideMatch[1]), {
|
|
1010
|
+
decision: body.decision, decided_by: device.machine_id, auto: body.auto === true,
|
|
1011
|
+
});
|
|
1012
|
+
if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'admission decision refused: ' + result.code, result);
|
|
1013
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (method === 'GET' && admissionOneMatch) {
|
|
1017
|
+
const request = this.admission.getRequest(decodeURIComponent(admissionOneMatch[1]));
|
|
1018
|
+
if (request === null) throw new ProtocolError('NOT_FOUND', 'unknown admission request');
|
|
1019
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), request });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (method === 'POST' && memberRemoveMatch) {
|
|
1023
|
+
const body = takeJson();
|
|
1024
|
+
const result = this.admission.remove(decodeURIComponent(memberRemoveMatch[1]), {
|
|
1025
|
+
requested_by: device.machine_id, reason: typeof body.reason === 'string' ? body.reason : null,
|
|
1026
|
+
});
|
|
1027
|
+
if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'removal refused: ' + result.code, result);
|
|
1028
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (method === 'POST' && removalVoteMatch) {
|
|
1032
|
+
const body = takeJson();
|
|
1033
|
+
const result = this.admission.vote(decodeURIComponent(removalVoteMatch[1]), {
|
|
1034
|
+
machine_id: device.machine_id, vote: body.vote,
|
|
1035
|
+
});
|
|
1036
|
+
if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'vote refused: ' + result.code, result);
|
|
1037
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
970
1040
|
throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
|
|
971
1041
|
}
|
|
972
1042
|
|
package/src/relay/state.mjs
CHANGED
|
@@ -876,6 +876,31 @@ export class RabbitState {
|
|
|
876
876
|
}
|
|
877
877
|
|
|
878
878
|
/** §2.2 step 3: validate a one-time code. */
|
|
879
|
+
/**
|
|
880
|
+
* The account fingerprint -> public key registry (v1.1.6).
|
|
881
|
+
*
|
|
882
|
+
* Keyed by the FINGERPRINT, not by machine: every machine on one account shares the derived keypair, and the point of
|
|
883
|
+
* the registry is to answer "which key speaks for this account", once, for all of them.
|
|
884
|
+
*
|
|
885
|
+
* @returns {{registered: boolean, reason?: string}}
|
|
886
|
+
*/
|
|
887
|
+
registerAccountKey(fingerprint, publicKeyPem) {
|
|
888
|
+
if (typeof fingerprint !== 'string' || fingerprint === '') return { registered: false, reason: 'BAD_FINGERPRINT' };
|
|
889
|
+
if (typeof publicKeyPem !== 'string' || publicKeyPem === '') return { registered: false, reason: 'BAD_PUBLIC_KEY' };
|
|
890
|
+
this.accountKeys ??= new Map();
|
|
891
|
+
const existing = this.accountKeys.get(fingerprint);
|
|
892
|
+
if (existing !== undefined) {
|
|
893
|
+
return { registered: false, reason: existing === publicKeyPem ? 'ALREADY_REGISTERED' : 'KEY_MISMATCH' };
|
|
894
|
+
}
|
|
895
|
+
this.accountKeys.set(fingerprint, publicKeyPem);
|
|
896
|
+
return { registered: true };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** @returns {string|null} the key that speaks for this account, or null if no machine ever paired for it. */
|
|
900
|
+
accountKeyFor(fingerprint) {
|
|
901
|
+
return this.accountKeys?.get(fingerprint) ?? null;
|
|
902
|
+
}
|
|
903
|
+
|
|
879
904
|
validatePairingCode(code) {
|
|
880
905
|
if (typeof code !== 'string' || code.length === 0) {
|
|
881
906
|
throw new ProtocolError('PAIRING_INVALID', 'pairing_code is required');
|
|
@@ -987,6 +1012,27 @@ export class RabbitState {
|
|
|
987
1012
|
};
|
|
988
1013
|
this.devices.set(machineId, device);
|
|
989
1014
|
this.tokens.set(token, machineId);
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* v1.1.6: register the account's signing key while we have proof of the account.
|
|
1018
|
+
*
|
|
1019
|
+
* A machine that failed the old joining path must later prove the account to the relay. It can only do that against
|
|
1020
|
+
* a key the relay already trusts, and the only moment the relay has real proof ("this machine holds the account
|
|
1021
|
+
* material") is a successful pairing. So this is the trust anchor, and it is written HERE rather than at
|
|
1022
|
+
* registration time to keep the two paths honest: no pairing, no key.
|
|
1023
|
+
*
|
|
1024
|
+
* Idempotent on purpose: the first machine to pair for a fingerprint fixes the key. A second machine claiming the
|
|
1025
|
+
* same fingerprint with a DIFFERENT key is not silently accepted -- the registration is refused and reported, and
|
|
1026
|
+
* changing a key needs the fleet vote the removal flow already provides.
|
|
1027
|
+
*/
|
|
1028
|
+
const accountFingerprint = typeof request.account_fingerprint === 'string' ? request.account_fingerprint : '';
|
|
1029
|
+
const accountPublicKey = typeof request.account_public_key === 'string' ? request.account_public_key : '';
|
|
1030
|
+
const accountKey = accountFingerprint !== '' && accountPublicKey !== ''
|
|
1031
|
+
? this.registerAccountKey(accountFingerprint, accountPublicKey)
|
|
1032
|
+
: null;
|
|
1033
|
+
// Recorded on the device, not just discarded: a second machine claiming the same account with a different key is
|
|
1034
|
+
// refused, and the refusal must be visible in devices.json rather than looking like a successful registration.
|
|
1035
|
+
device.account_key = accountKey;
|
|
990
1036
|
rec.used = true;
|
|
991
1037
|
|
|
992
1038
|
// A group has more than one machine, and a single-use code would leave the
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account's own signing key.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A fleet key is a **symmetric** secret: it proves "this machine is allowed into the fleet derived from this account",
|
|
7
|
+
* and it is the only account material the system had. That is enough for the old joining path and not enough for the
|
|
8
|
+
* new one -- a machine that failed to pair must *prove the account* to a relay that has never met it, and proof of that
|
|
9
|
+
* kind is a signature.
|
|
10
|
+
*
|
|
11
|
+
* So the account gains an asymmetric identity. It is DERIVED, not generated: the same account material yields the same
|
|
12
|
+
* keypair on every machine, which is what makes "zero configuration" possible at all -- a fresh install can sign with
|
|
13
|
+
* an identity nobody had to copy onto it. The matching public key is registered with the relay when the account's first
|
|
14
|
+
* machine pairs (see the relay's account-key registry), and later machines are verified against it.
|
|
15
|
+
*
|
|
16
|
+
* ## Ed25519 from a seed, without a new dependency
|
|
17
|
+
*
|
|
18
|
+
* Node can import an Ed25519 private key from PKCS#8 DER, and PKCS#8 for Ed25519 is a fixed 16-byte prefix followed by
|
|
19
|
+
* the 32-byte seed. That is the whole construction; no library, and the output is the standard RFC 8032 key, so any
|
|
20
|
+
* other verifier accepts it.
|
|
21
|
+
*
|
|
22
|
+
* The derivation domain-separates with a label, so a seed reused elsewhere cannot accidentally produce this key.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, createPrivateKey, createPublicKey, sign as signWith, verify as verifyWith } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
/** PKCS#8 prefix for Ed25519: OID 1.3.101.112 inside a OneAsymmetricKey with the seed as the private key. */
|
|
27
|
+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
28
|
+
/** Domain separation: this key exists only for fleet joining. */
|
|
29
|
+
const LABEL = 'w2m-account-identity-v1';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string|Buffer} accountMaterial the account's stable material (never sent anywhere by this function)
|
|
33
|
+
* @returns {{privateKey: import('node:crypto').KeyObject, publicKey: import('node:crypto').KeyObject,
|
|
34
|
+
* publicKeyPem: string, fingerprint: string}}
|
|
35
|
+
*/
|
|
36
|
+
export function deriveAccountKeypair(accountMaterial) {
|
|
37
|
+
if (typeof accountMaterial !== 'string' && !Buffer.isBuffer(accountMaterial)) {
|
|
38
|
+
throw new TypeError('accountMaterial must be a string or a Buffer');
|
|
39
|
+
}
|
|
40
|
+
const material = Buffer.isBuffer(accountMaterial) ? accountMaterial : Buffer.from(accountMaterial, 'utf8');
|
|
41
|
+
if (material.length === 0) throw new TypeError('accountMaterial must not be empty');
|
|
42
|
+
// Derive a 32-byte seed; the label is part of the input so this cannot collide with another use of the same material.
|
|
43
|
+
const seed = createHash('sha256').update(LABEL).update(Buffer.from([0x00])).update(material).digest();
|
|
44
|
+
const privateKey = createPrivateKey({
|
|
45
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
|
|
46
|
+
format: 'der',
|
|
47
|
+
type: 'pkcs8',
|
|
48
|
+
});
|
|
49
|
+
const publicKey = createPublicKey(privateKey);
|
|
50
|
+
const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' });
|
|
51
|
+
return {
|
|
52
|
+
privateKey,
|
|
53
|
+
publicKey,
|
|
54
|
+
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
55
|
+
// The fingerprint IS the public key, identified by hash: a relay can verify a signature against the key it holds
|
|
56
|
+
// and check that the hash matches, with no registration of the account anywhere.
|
|
57
|
+
fingerprint: createHash('sha256').update(publicKeyDer).digest('hex'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Sign the challenge payload. The relay verifies; nothing here decides policy. */
|
|
62
|
+
export function signChallenge(identity, payload) {
|
|
63
|
+
const data = typeof payload === 'string' ? Buffer.from(payload, 'utf8') : Buffer.from(payload);
|
|
64
|
+
return signWith(null, data, identity.privateKey).toString('base64');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Verify a signature against a PEM public key. Returns a boolean: a thrown error here would be a 500 for a bad login. */
|
|
68
|
+
export function verifyChallenge(publicKeyPem, payload, signatureBase64) {
|
|
69
|
+
const data = typeof payload === 'string' ? Buffer.from(payload, 'utf8') : Buffer.from(payload);
|
|
70
|
+
try {
|
|
71
|
+
return verifyWith(null, data, createPublicKey(publicKeyPem), Buffer.from(String(signatureBase64), 'base64'));
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|