@oxy.so/core 1.7.0 → 1.7.3
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/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/registrationPow.js +53 -0
- package/dist/cjs/mixins/OxyServices.auth.js +12 -6
- package/dist/cjs/server/workloadIdentity.client.js +11 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/registrationPow.js +50 -0
- package/dist/esm/mixins/OxyServices.auth.js +12 -6
- package/dist/esm/mixins/OxyServices.identityBackup.js +1 -1
- package/dist/esm/server/workloadIdentity.client.js +7 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/registrationPow.d.ts +22 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
- package/dist/types/server/workloadIdentity.client.d.ts +4 -0
- package/package.json +14 -4
- package/src/__tests__/fixEsmImports.test.ts +132 -0
- package/src/crypto/__tests__/registrationPow.test.ts +61 -0
- package/src/crypto/registrationPow.ts +60 -0
- package/src/mixins/OxyServices.auth.ts +13 -6
- package/src/server/__tests__/workloadIdentity.client.test.ts +16 -0
- package/src/server/workloadIdentity.client.ts +12 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the CLIENT half of the anti-automation nonce
|
|
3
|
+
* `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
|
|
4
|
+
* registration signature. The message format, difficulty and the full design
|
|
5
|
+
* rationale (why 16 bits, why sequential nonces are fine, what this is and
|
|
6
|
+
* isn't a defense against) live in `@oxy.so/protocol`'s
|
|
7
|
+
* `auth/registrationPow.ts` — this file is only the solve loop.
|
|
8
|
+
*/
|
|
9
|
+
import './polyfill';
|
|
10
|
+
/**
|
|
11
|
+
* Grind a nonce for `publicKey`/`timestamp` that clears
|
|
12
|
+
* {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
|
|
13
|
+
*
|
|
14
|
+
* Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
|
|
15
|
+
* on web/Node/React Native with no native module and no per-attempt bridge
|
|
16
|
+
* crossing (see the difficulty constant's own doc for why that distinction is
|
|
17
|
+
* the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
|
|
18
|
+
* candidate space a hash function spreads solutions across is dense either
|
|
19
|
+
* way, so a counter finds one exactly as reliably as randomness would, without
|
|
20
|
+
* needing a randomness source at all.
|
|
21
|
+
*/
|
|
22
|
+
export declare function solveRegistrationPow(publicKey: string, timestamp: number): Promise<string>;
|
|
@@ -391,10 +391,10 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
391
391
|
/**
|
|
392
392
|
* Whether this process can prove what it is (ADR 0026).
|
|
393
393
|
*
|
|
394
|
-
* The
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
394
|
+
* The internal package subpath selects a client implementation for native
|
|
395
|
+
* and browser bundlers, and the signer only for Node hosts. The runtime
|
|
396
|
+
* Node guard avoids calling attestation on clients; the module boundary
|
|
397
|
+
* keeps node:crypto out of their bundle graphs (a guard alone cannot).
|
|
398
398
|
*
|
|
399
399
|
* @internal
|
|
400
400
|
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { WorkloadServiceToken, WorkloadServiceTokenOptions } from './workloadIdentity';
|
|
2
|
+
/** Workload attestation belongs to a Node host, never a browser or device. */
|
|
3
|
+
export declare function canAttestWorkloadIdentity(): boolean;
|
|
4
|
+
export declare function requestWorkloadServiceToken(_options: WorkloadServiceTokenOptions): Promise<WorkloadServiceToken>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -59,7 +59,17 @@
|
|
|
59
59
|
},
|
|
60
60
|
"default": "./dist/esm/logger/index.js"
|
|
61
61
|
},
|
|
62
|
-
"./package.json": "./package.json"
|
|
62
|
+
"./package.json": "./package.json",
|
|
63
|
+
"./internal/workload-identity": {
|
|
64
|
+
"types": "./dist/types/server/workloadIdentity.d.ts",
|
|
65
|
+
"react-native": "./dist/esm/server/workloadIdentity.client.js",
|
|
66
|
+
"browser": "./dist/esm/server/workloadIdentity.client.js",
|
|
67
|
+
"node": {
|
|
68
|
+
"import": "./dist/esm/server/workloadIdentity.js",
|
|
69
|
+
"require": "./dist/cjs/server/workloadIdentity.js"
|
|
70
|
+
},
|
|
71
|
+
"default": "./dist/esm/server/workloadIdentity.client.js"
|
|
72
|
+
}
|
|
63
73
|
},
|
|
64
74
|
"files": [
|
|
65
75
|
"NOTICE",
|
|
@@ -80,7 +90,7 @@
|
|
|
80
90
|
"directory": "packages/core"
|
|
81
91
|
},
|
|
82
92
|
"author": "OxyHQ",
|
|
83
|
-
"license": "
|
|
93
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
84
94
|
"homepage": "https://oxy.so",
|
|
85
95
|
"engines": {
|
|
86
96
|
"node": ">=18.0.0"
|
|
@@ -119,7 +129,7 @@
|
|
|
119
129
|
"@noble/ciphers": "^1.3.0",
|
|
120
130
|
"@noble/hashes": "^1.8.0",
|
|
121
131
|
"@oxy.so/contracts": "^1.3.0",
|
|
122
|
-
"@oxy.so/protocol": "^1.0
|
|
132
|
+
"@oxy.so/protocol": "^1.1.0",
|
|
123
133
|
"@scure/bip39": "^1.6.0",
|
|
124
134
|
"buffer": "^6.0.3",
|
|
125
135
|
"express-rate-limit": "^8.6.0",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scripts/fix-esm-imports.mjs` must rewrite DYNAMIC relative imports too.
|
|
3
|
+
*
|
|
4
|
+
* ## The failure this exists for, which happened
|
|
5
|
+
*
|
|
6
|
+
* This package writes relative specifiers without an extension — legal under
|
|
7
|
+
* `moduleResolution: bundler`, illegal in Node's ESM resolver — and the
|
|
8
|
+
* post-build script adds the extension back. For its first life the script's
|
|
9
|
+
* pattern was `(?:from|import)\s+['"]`, which cannot match `import('./x')`:
|
|
10
|
+
* the keyword is followed by a parenthesis, not whitespace. Every dynamic
|
|
11
|
+
* import therefore shipped extensionless in `dist/esm`.
|
|
12
|
+
*
|
|
13
|
+
* `OxyServices.getServiceToken()` loads `../server/workloadIdentity` that way
|
|
14
|
+
* to decide whether this process can attest its workload identity (ADR 0026),
|
|
15
|
+
* and it does so inside a `try/catch` that reads any failure as "cannot attest
|
|
16
|
+
* here". So in every ESM consumer of the BUILT package the ADR 0026 fallback
|
|
17
|
+
* was dead, and a service whose task role attests perfectly well was told
|
|
18
|
+
* `Service credentials not provided` — which is what a deployment sees the
|
|
19
|
+
* moment its key pair is removed, and the reason `mention-mcp` and `alia` could
|
|
20
|
+
* not complete the migration while `mention`, `allo` and `alia-integrations`
|
|
21
|
+
* (all CommonJS, where `require()` supplies the extension) already had.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this spawns the real script
|
|
24
|
+
*
|
|
25
|
+
* The defect is in the build OUTPUT, and core's CI job runs jest without
|
|
26
|
+
* building the package, so a test that reads `dist/esm` would pass by finding
|
|
27
|
+
* nothing. `serviceTokenWorkloadFallback.test.ts` cannot catch it either: jest
|
|
28
|
+
* resolves extensionless specifiers, and it mocks that module by name. The one
|
|
29
|
+
* thing that measures the real behaviour without a build is to run the script
|
|
30
|
+
* over a fixture and read what it wrote.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { tmpdir } from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
const script = path.resolve(__dirname, '../../scripts/fix-esm-imports.mjs');
|
|
39
|
+
|
|
40
|
+
function runOnFixture(files: Record<string, string>): Record<string, string> {
|
|
41
|
+
const root = mkdtempSync(path.join(tmpdir(), 'oxy-core-esm-fix-'));
|
|
42
|
+
try {
|
|
43
|
+
for (const [name, source] of Object.entries(files)) {
|
|
44
|
+
const full = path.join(root, name);
|
|
45
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
46
|
+
writeFileSync(full, source);
|
|
47
|
+
}
|
|
48
|
+
const result = spawnSync(process.execPath, [script, root], { encoding: 'utf8' });
|
|
49
|
+
expect(result.status).toBe(0);
|
|
50
|
+
const out: Record<string, string> = {};
|
|
51
|
+
for (const name of Object.keys(files)) {
|
|
52
|
+
out[name] = readFileSync(path.join(root, name), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('fix-esm-imports', () => {
|
|
61
|
+
it('adds .js to a dynamic relative import', () => {
|
|
62
|
+
const out = runOnFixture({
|
|
63
|
+
'server/workloadIdentity.js': 'export const canAttestWorkloadIdentity = () => true;\n',
|
|
64
|
+
'mixins/auth.js': [
|
|
65
|
+
'export async function canUse() {',
|
|
66
|
+
" const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity');",
|
|
67
|
+
' return canAttestWorkloadIdentity();',
|
|
68
|
+
'}',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(out['mixins/auth.js']).toContain("await import('../server/workloadIdentity.js')");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rewrites every dynamic import in a file, not only the first', () => {
|
|
77
|
+
const out = runOnFixture({
|
|
78
|
+
'server/workloadIdentity.js': 'export const a = 1;\nexport const b = 2;\n',
|
|
79
|
+
'mixins/auth.js': [
|
|
80
|
+
"const first = await import('../server/workloadIdentity');",
|
|
81
|
+
"const second = await import('../server/workloadIdentity');",
|
|
82
|
+
'',
|
|
83
|
+
].join('\n'),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const matches = out['mixins/auth.js'].match(/workloadIdentity\.js/g) ?? [];
|
|
87
|
+
expect(matches).toHaveLength(2);
|
|
88
|
+
// And nothing was left extensionless.
|
|
89
|
+
expect(out['mixins/auth.js']).not.toMatch(/workloadIdentity'/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('tolerates whitespace between the keyword and the parenthesis', () => {
|
|
93
|
+
const out = runOnFixture({
|
|
94
|
+
'server/workloadIdentity.js': 'export const a = 1;\n',
|
|
95
|
+
'mixins/auth.js': "const m = await import(\n '../server/workloadIdentity'\n);\n",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(out['mixins/auth.js']).toContain("'../server/workloadIdentity.js'");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resolves a dynamic directory import to its index', () => {
|
|
102
|
+
const out = runOnFixture({
|
|
103
|
+
'server/index.js': 'export const a = 1;\n',
|
|
104
|
+
'mixins/auth.js': "const m = await import('../server');\n",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(out['mixins/auth.js']).toContain("'../server/index.js'");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('still rewrites the static forms, and leaves bare and already-suffixed specifiers alone', () => {
|
|
111
|
+
const out = runOnFixture({
|
|
112
|
+
'server/workloadIdentity.js': 'export const a = 1;\n',
|
|
113
|
+
'mixins/auth.js': [
|
|
114
|
+
"import { a } from '../server/workloadIdentity';",
|
|
115
|
+
"import '../server/workloadIdentity';",
|
|
116
|
+
"export { a as b } from '../server/workloadIdentity';",
|
|
117
|
+
"import { createHash } from 'node:crypto';",
|
|
118
|
+
"import { z } from 'zod';",
|
|
119
|
+
"const late = await import('./sibling.js');",
|
|
120
|
+
'',
|
|
121
|
+
].join('\n'),
|
|
122
|
+
'mixins/sibling.js': 'export const s = 1;\n',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const source = out['mixins/auth.js'];
|
|
126
|
+
expect(source.match(/workloadIdentity\.js/g) ?? []).toHaveLength(3);
|
|
127
|
+
expect(source).toContain("import { createHash } from 'node:crypto';");
|
|
128
|
+
expect(source).toContain("import { z } from 'zod';");
|
|
129
|
+
expect(source).toContain("await import('./sibling.js')");
|
|
130
|
+
expect(source).not.toContain('sibling.js.js');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of registration proof-of-work: `solveRegistrationPow` must
|
|
3
|
+
* find a nonce that an INDEPENDENT hasher (not the one it used to search)
|
|
4
|
+
* agrees clears the shared difficulty — otherwise the client and the server
|
|
5
|
+
* (`packages/api`, which hashes with Node's `crypto`, not `@noble/hashes`)
|
|
6
|
+
* could silently disagree about what "solved" means.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import {
|
|
11
|
+
meetsRegistrationPowDifficulty,
|
|
12
|
+
registrationPowMessage,
|
|
13
|
+
REGISTRATION_POW_DIFFICULTY_BITS,
|
|
14
|
+
} from '@oxy.so/protocol';
|
|
15
|
+
import { solveRegistrationPow } from '../registrationPow';
|
|
16
|
+
|
|
17
|
+
function nodeDigestOf(publicKey: string, timestamp: number, nonce: string): string {
|
|
18
|
+
return crypto
|
|
19
|
+
.createHash('sha256')
|
|
20
|
+
.update(registrationPowMessage(publicKey, timestamp, nonce))
|
|
21
|
+
.digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('solveRegistrationPow', () => {
|
|
25
|
+
it('finds a nonce that Node crypto (not @noble/hashes) also agrees clears the difficulty', async () => {
|
|
26
|
+
const publicKey = 'test-public-key';
|
|
27
|
+
const timestamp = 1_700_000_000_000;
|
|
28
|
+
|
|
29
|
+
const nonce = await solveRegistrationPow(publicKey, timestamp);
|
|
30
|
+
const digest = nodeDigestOf(publicKey, timestamp, nonce);
|
|
31
|
+
|
|
32
|
+
expect(meetsRegistrationPowDifficulty(digest, REGISTRATION_POW_DIFFICULTY_BITS)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('is deterministic: the same publicKey/timestamp always yields the same nonce', async () => {
|
|
36
|
+
const publicKey = 'deterministic-key';
|
|
37
|
+
const timestamp = 1_700_000_000_001;
|
|
38
|
+
|
|
39
|
+
const first = await solveRegistrationPow(publicKey, timestamp);
|
|
40
|
+
const second = await solveRegistrationPow(publicKey, timestamp);
|
|
41
|
+
|
|
42
|
+
expect(second).toBe(first);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('binds the nonce to publicKey and timestamp, not just to itself', async () => {
|
|
46
|
+
const timestamp = 1_700_000_000_002;
|
|
47
|
+
const nonce = await solveRegistrationPow('bound-key', timestamp);
|
|
48
|
+
|
|
49
|
+
// The nonce solves its OWN message under the shared difficulty check...
|
|
50
|
+
expect(
|
|
51
|
+
meetsRegistrationPowDifficulty(nodeDigestOf('bound-key', timestamp, nonce), REGISTRATION_POW_DIFFICULTY_BITS)
|
|
52
|
+
).toBe(true);
|
|
53
|
+
// ...but the message it was solved for is bound to that exact publicKey —
|
|
54
|
+
// reusing the nonce string under an unrelated one hashes to something
|
|
55
|
+
// unrelated, i.e. `registrationPowMessage` actually mixes `publicKey` in
|
|
56
|
+
// rather than the digest depending on `nonce` alone.
|
|
57
|
+
expect(registrationPowMessage('bound-key', timestamp, nonce)).not.toBe(
|
|
58
|
+
registrationPowMessage('a-different-key', timestamp, nonce)
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the CLIENT half of the anti-automation nonce
|
|
3
|
+
* `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
|
|
4
|
+
* registration signature. The message format, difficulty and the full design
|
|
5
|
+
* rationale (why 16 bits, why sequential nonces are fine, what this is and
|
|
6
|
+
* isn't a defense against) live in `@oxy.so/protocol`'s
|
|
7
|
+
* `auth/registrationPow.ts` — this file is only the solve loop.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import './polyfill';
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
12
|
+
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
|
|
13
|
+
import {
|
|
14
|
+
meetsRegistrationPowDifficulty,
|
|
15
|
+
registrationPowMessage,
|
|
16
|
+
REGISTRATION_POW_DIFFICULTY_BITS,
|
|
17
|
+
} from '@oxy.so/protocol';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How many attempts the grind loop runs before yielding to the event loop.
|
|
21
|
+
*
|
|
22
|
+
* A synchronous hot loop long enough to matter (an average solve at 16 bits
|
|
23
|
+
* is 65,536 attempts) would otherwise block the JS thread for its entire
|
|
24
|
+
* duration — on a mobile app that means no touch response, no animation, for
|
|
25
|
+
* up to roughly a second. Yielding periodically keeps the UI responsive
|
|
26
|
+
* during the grind at the cost of a handful of event-loop round trips, which
|
|
27
|
+
* is imperceptible next to the grind itself.
|
|
28
|
+
*/
|
|
29
|
+
const YIELD_EVERY_N_ATTEMPTS = 2000;
|
|
30
|
+
|
|
31
|
+
function yieldToEventLoop(): Promise<void> {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Grind a nonce for `publicKey`/`timestamp` that clears
|
|
37
|
+
* {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
|
|
38
|
+
*
|
|
39
|
+
* Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
|
|
40
|
+
* on web/Node/React Native with no native module and no per-attempt bridge
|
|
41
|
+
* crossing (see the difficulty constant's own doc for why that distinction is
|
|
42
|
+
* the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
|
|
43
|
+
* candidate space a hash function spreads solutions across is dense either
|
|
44
|
+
* way, so a counter finds one exactly as reliably as randomness would, without
|
|
45
|
+
* needing a randomness source at all.
|
|
46
|
+
*/
|
|
47
|
+
export async function solveRegistrationPow(publicKey: string, timestamp: number): Promise<string> {
|
|
48
|
+
for (let nonce = 0; ; nonce += 1) {
|
|
49
|
+
const candidate = String(nonce);
|
|
50
|
+
const digest = bytesToHex(
|
|
51
|
+
sha256(utf8ToBytes(registrationPowMessage(publicKey, timestamp, candidate)))
|
|
52
|
+
);
|
|
53
|
+
if (meetsRegistrationPowDifficulty(digest, REGISTRATION_POW_DIFFICULTY_BITS)) {
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
if (nonce % YIELD_EVERY_N_ATTEMPTS === YIELD_EVERY_N_ATTEMPTS - 1) {
|
|
57
|
+
await yieldToEventLoop();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
} from '../utils/commonsApproval';
|
|
20
20
|
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
21
21
|
import { KeyManager } from '../crypto/keyManager';
|
|
22
|
+
import { solveRegistrationPow } from '../crypto/registrationPow';
|
|
22
23
|
import { SignatureService } from '../crypto/signatureService';
|
|
23
24
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
24
25
|
import { logger } from '../logger';
|
|
@@ -646,17 +647,17 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
646
647
|
/**
|
|
647
648
|
* Whether this process can prove what it is (ADR 0026).
|
|
648
649
|
*
|
|
649
|
-
* The
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
650
|
+
* The internal package subpath selects a client implementation for native
|
|
651
|
+
* and browser bundlers, and the signer only for Node hosts. The runtime
|
|
652
|
+
* Node guard avoids calling attestation on clients; the module boundary
|
|
653
|
+
* keeps node:crypto out of their bundle graphs (a guard alone cannot).
|
|
653
654
|
*
|
|
654
655
|
* @internal
|
|
655
656
|
*/
|
|
656
657
|
async _canUseWorkloadIdentity(): Promise<boolean> {
|
|
657
658
|
if (typeof process === 'undefined' || !process.versions?.node) return false;
|
|
658
659
|
try {
|
|
659
|
-
const { canAttestWorkloadIdentity } = await import('
|
|
660
|
+
const { canAttestWorkloadIdentity } = await import('@oxy.so/core/internal/workload-identity');
|
|
660
661
|
return canAttestWorkloadIdentity();
|
|
661
662
|
} catch {
|
|
662
663
|
// A bundler that dropped the server subpath, or a runtime without it.
|
|
@@ -683,7 +684,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
683
684
|
if (entry?.token && entry.expiresAt > now + 60_000) return entry.token;
|
|
684
685
|
if (entry?.pending) return entry.pending;
|
|
685
686
|
|
|
686
|
-
const { requestWorkloadServiceToken } = await import('
|
|
687
|
+
const { requestWorkloadServiceToken } = await import('@oxy.so/core/internal/workload-identity');
|
|
687
688
|
const seeded = entry ?? {
|
|
688
689
|
token: '',
|
|
689
690
|
expiresAt: 0,
|
|
@@ -844,10 +845,16 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
844
845
|
timestamp: number
|
|
845
846
|
): Promise<{ message: string; user: User }> {
|
|
846
847
|
try {
|
|
848
|
+
// Advisory for now (server soft-enforces — see
|
|
849
|
+
// `SessionController.register`), but solved unconditionally so every
|
|
850
|
+
// client is already sending it once the server starts requiring it.
|
|
851
|
+
const powNonce = await solveRegistrationPow(publicKey, timestamp);
|
|
852
|
+
|
|
847
853
|
const res = await this.makeRequest<{ message: string; user: User }>('POST', '/auth/register', {
|
|
848
854
|
publicKey,
|
|
849
855
|
signature,
|
|
850
856
|
timestamp,
|
|
857
|
+
powNonce,
|
|
851
858
|
}, { cache: false, skipAuth: true });
|
|
852
859
|
|
|
853
860
|
if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { canAttestWorkloadIdentity, requestWorkloadServiceToken } from '../workloadIdentity.client';
|
|
2
|
+
|
|
3
|
+
describe('client workload identity boundary', () => {
|
|
4
|
+
it('cannot attest even when Node process globals are present', () => {
|
|
5
|
+
expect(process.versions.node).toBeTruthy();
|
|
6
|
+
expect(canAttestWorkloadIdentity()).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('rejects before contacting a server when explicitly requested', async () => {
|
|
10
|
+
const fetchSpy = jest.spyOn(globalThis, 'fetch');
|
|
11
|
+
await expect(requestWorkloadServiceToken({ baseUrl: 'https://example.test' }))
|
|
12
|
+
.rejects.toThrow('only available on a Node host');
|
|
13
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
14
|
+
fetchSpy.mockRestore();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkloadServiceToken, WorkloadServiceTokenOptions } from './workloadIdentity';
|
|
2
|
+
|
|
3
|
+
/** Workload attestation belongs to a Node host, never a browser or device. */
|
|
4
|
+
export function canAttestWorkloadIdentity(): boolean {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function requestWorkloadServiceToken(
|
|
9
|
+
_options: WorkloadServiceTokenOptions,
|
|
10
|
+
): Promise<WorkloadServiceToken> {
|
|
11
|
+
throw new Error('Workload identity is only available on a Node host');
|
|
12
|
+
}
|